mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
Asset Browser Modal Component (#5607)
* [ci] ignore playwright mcp directory * [feat] add AssetBrowserModal And all related sub components * [feat] reactive filter functions * [ci] clean up storybook config * [feat] add sematic AssetCard * [fix] i love lucide * [fix] AssetCard layout issues * [fix] add AssetBadge type * [fix] simplify useAssetBrowser * [fix] modal layout * [fix] simplify useAssetBrowserDialog * [fix] add tailwind back to storybook * [fix] better reponsive layout * [fix] missed i18n string * [fix] missing i18n translations * [fix] remove erroneous prevent on keyboard.space * [feat] add asset metadata validation utilities * [fix] remove erroneous test code * [fix] remove forced min and max width on AssetCard * [fix] import statement nits
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -78,8 +78,8 @@ vite.config.mts.timestamp-*.mjs
|
|||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
|
# MCP Servers
|
||||||
|
.playwright-mcp/*
|
||||||
|
|
||||||
.nx/cache
|
.nx/cache
|
||||||
.nx/workspace-data
|
.nx/workspace-data
|
||||||
|
|||||||
@@ -15,21 +15,32 @@ const config: StorybookConfig = {
|
|||||||
async viteFinal(config) {
|
async viteFinal(config) {
|
||||||
// Use dynamic import to avoid CJS deprecation warning
|
// Use dynamic import to avoid CJS deprecation warning
|
||||||
const { mergeConfig } = await import('vite')
|
const { mergeConfig } = await import('vite')
|
||||||
|
const { default: tailwindcss } = await import('@tailwindcss/vite')
|
||||||
|
|
||||||
// Filter out any plugins that might generate import maps
|
// Filter out any plugins that might generate import maps
|
||||||
if (config.plugins) {
|
if (config.plugins) {
|
||||||
config.plugins = config.plugins.filter((plugin: any) => {
|
config.plugins = config.plugins
|
||||||
if (plugin && plugin.name && plugin.name.includes('import-map')) {
|
// Type guard: ensure we have valid plugin objects with names
|
||||||
return false
|
.filter(
|
||||||
}
|
(plugin): plugin is NonNullable<typeof plugin> & { name: string } => {
|
||||||
return true
|
return (
|
||||||
})
|
plugin !== null &&
|
||||||
|
plugin !== undefined &&
|
||||||
|
typeof plugin === 'object' &&
|
||||||
|
'name' in plugin &&
|
||||||
|
typeof plugin.name === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Business logic: filter out import-map plugins
|
||||||
|
.filter((plugin) => !plugin.name.includes('import-map'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergeConfig(config, {
|
return mergeConfig(config, {
|
||||||
// Replace plugins entirely to avoid inheritance issues
|
// Replace plugins entirely to avoid inheritance issues
|
||||||
plugins: [
|
plugins: [
|
||||||
// Only include plugins we explicitly need for Storybook
|
// Only include plugins we explicitly need for Storybook
|
||||||
|
tailwindcss(),
|
||||||
Icons({
|
Icons({
|
||||||
compiler: 'vue3',
|
compiler: 'vue3',
|
||||||
customCollections: {
|
customCollections: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { definePreset } from '@primevue/themes'
|
import { definePreset } from '@primevue/themes'
|
||||||
import Aura from '@primevue/themes/aura'
|
import Aura from '@primevue/themes/aura'
|
||||||
import { setup } from '@storybook/vue3'
|
import { setup } from '@storybook/vue3'
|
||||||
import type { Preview } from '@storybook/vue3-vite'
|
import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import 'primeicons/primeicons.css'
|
import 'primeicons/primeicons.css'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
@@ -9,11 +9,9 @@ import ConfirmationService from 'primevue/confirmationservice'
|
|||||||
import ToastService from 'primevue/toastservice'
|
import ToastService from 'primevue/toastservice'
|
||||||
import Tooltip from 'primevue/tooltip'
|
import Tooltip from 'primevue/tooltip'
|
||||||
|
|
||||||
import '../src/assets/css/style.css'
|
import '@/assets/css/style.css'
|
||||||
import { i18n } from '../src/i18n'
|
import { i18n } from '@/i18n'
|
||||||
import '../src/lib/litegraph/public/css/litegraph.css'
|
import '@/lib/litegraph/public/css/litegraph.css'
|
||||||
import { useWidgetStore } from '../src/stores/widgetStore'
|
|
||||||
import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore'
|
|
||||||
|
|
||||||
const ComfyUIPreset = definePreset(Aura, {
|
const ComfyUIPreset = definePreset(Aura, {
|
||||||
semantic: {
|
semantic: {
|
||||||
@@ -25,13 +23,11 @@ const ComfyUIPreset = definePreset(Aura, {
|
|||||||
// Setup Vue app for Storybook
|
// Setup Vue app for Storybook
|
||||||
setup((app) => {
|
setup((app) => {
|
||||||
app.directive('tooltip', Tooltip)
|
app.directive('tooltip', Tooltip)
|
||||||
|
|
||||||
|
// Create Pinia instance
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
// Initialize stores
|
|
||||||
useColorPaletteStore(pinia)
|
|
||||||
useWidgetStore(pinia)
|
|
||||||
|
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
theme: {
|
theme: {
|
||||||
@@ -50,8 +46,8 @@ setup((app) => {
|
|||||||
app.use(ToastService)
|
app.use(ToastService)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Dark theme decorator
|
// Theme and dialog decorator
|
||||||
export const withTheme = (Story: any, context: any) => {
|
export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||||
const theme = context.globals.theme || 'light'
|
const theme = context.globals.theme || 'light'
|
||||||
|
|
||||||
// Apply theme class to document root
|
// Apply theme class to document root
|
||||||
@@ -63,7 +59,7 @@ export const withTheme = (Story: any, context: any) => {
|
|||||||
document.body.classList.remove('dark-theme')
|
document.body.classList.remove('dark-theme')
|
||||||
}
|
}
|
||||||
|
|
||||||
return Story()
|
return Story(context.args, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
|
|||||||
@@ -1862,5 +1862,17 @@
|
|||||||
"showGroups": "Show Frames/Groups",
|
"showGroups": "Show Frames/Groups",
|
||||||
"renderBypassState": "Render Bypass State",
|
"renderBypassState": "Render Bypass State",
|
||||||
"renderErrorState": "Render Error State"
|
"renderErrorState": "Render Error State"
|
||||||
|
},
|
||||||
|
"assetBrowser": {
|
||||||
|
"assets": "Assets",
|
||||||
|
"browseAssets": "Browse Assets",
|
||||||
|
"noAssetsFound": "No assets found",
|
||||||
|
"tryAdjustingFilters": "Try adjusting your search or filters",
|
||||||
|
"loadingModels": "Loading {type}...",
|
||||||
|
"connectionError": "Please check your connection and try again",
|
||||||
|
"noModelsInFolder": "No {type} available in this folder",
|
||||||
|
"searchAssetsPlaceholder": "Search assets...",
|
||||||
|
"allModels": "All Models",
|
||||||
|
"unknown": "Unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/platform/assets/components/AssetBadgeGroup.vue
Normal file
42
src/platform/assets/components/AssetBadgeGroup.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div class="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1">
|
||||||
|
<span
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.label"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
|
||||||
|
getBadgeColor(badge.type)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ badge.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
type AssetBadge = {
|
||||||
|
label: string
|
||||||
|
type: 'type' | 'base' | 'size'
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
badges: AssetBadge[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function getBadgeColor(type: AssetBadge['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'type':
|
||||||
|
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
|
||||||
|
case 'base':
|
||||||
|
return 'bg-success-100/90 dark-theme:bg-success-100/80'
|
||||||
|
case 'size':
|
||||||
|
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
|
||||||
|
default:
|
||||||
|
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
178
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
178
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||||
|
import {
|
||||||
|
createMockAssets,
|
||||||
|
mockAssets
|
||||||
|
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||||
|
|
||||||
|
// Story arguments interface
|
||||||
|
interface StoryArgs {
|
||||||
|
nodeType: string
|
||||||
|
inputName: string
|
||||||
|
currentValue: string
|
||||||
|
showLeftPanel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<StoryArgs> = {
|
||||||
|
title: 'Platform/Assets/AssetBrowserModal',
|
||||||
|
component: AssetBrowserModal,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen'
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
nodeType: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'],
|
||||||
|
description: 'ComfyUI node type for context'
|
||||||
|
},
|
||||||
|
inputName: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['ckpt_name', 'vae_name', 'control_net_name'],
|
||||||
|
description: 'Widget input name'
|
||||||
|
},
|
||||||
|
currentValue: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Current selected asset value'
|
||||||
|
},
|
||||||
|
showLeftPanel: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether to show the left panel with categories'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// Modal Layout Stories
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
nodeType: 'CheckpointLoaderSimple',
|
||||||
|
inputName: 'ckpt_name',
|
||||||
|
currentValue: '',
|
||||||
|
showLeftPanel: false
|
||||||
|
},
|
||||||
|
render: (args) => ({
|
||||||
|
components: { AssetBrowserModal },
|
||||||
|
setup() {
|
||||||
|
const onAssetSelect = (asset: any) => {
|
||||||
|
console.log('Selected asset:', asset)
|
||||||
|
}
|
||||||
|
const onClose = () => {
|
||||||
|
console.log('Modal closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...args,
|
||||||
|
onAssetSelect,
|
||||||
|
onClose,
|
||||||
|
assets: mockAssets
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||||
|
<AssetBrowserModal
|
||||||
|
:node-type="nodeType"
|
||||||
|
:input-name="inputName"
|
||||||
|
:show-left-panel="showLeftPanel"
|
||||||
|
:assets="assets"
|
||||||
|
@asset-select="onAssetSelect"
|
||||||
|
@close="onClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Story demonstrating single asset type (auto-hides left panel)
|
||||||
|
export const SingleAssetType: Story = {
|
||||||
|
args: {
|
||||||
|
nodeType: 'CheckpointLoaderSimple',
|
||||||
|
inputName: 'ckpt_name',
|
||||||
|
currentValue: '',
|
||||||
|
showLeftPanel: false
|
||||||
|
},
|
||||||
|
render: (args) => ({
|
||||||
|
components: { AssetBrowserModal },
|
||||||
|
setup() {
|
||||||
|
const onAssetSelect = (asset: any) => {
|
||||||
|
console.log('Selected asset:', asset)
|
||||||
|
}
|
||||||
|
const onClose = () => {
|
||||||
|
console.log('Modal closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create assets with only one type (checkpoints)
|
||||||
|
const singleTypeAssets = createMockAssets(15).map((asset) => ({
|
||||||
|
...asset,
|
||||||
|
type: 'checkpoint'
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||||
|
<AssetBrowserModal
|
||||||
|
:node-type="nodeType"
|
||||||
|
:input-name="inputName"
|
||||||
|
:show-left-panel="showLeftPanel"
|
||||||
|
:assets="assets"
|
||||||
|
@asset-select="onAssetSelect"
|
||||||
|
@close="onClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Modal with assets of only one type (checkpoint) - left panel auto-hidden.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Story with left panel explicitly hidden
|
||||||
|
export const NoLeftPanel: Story = {
|
||||||
|
args: {
|
||||||
|
nodeType: 'CheckpointLoaderSimple',
|
||||||
|
inputName: 'ckpt_name',
|
||||||
|
currentValue: '',
|
||||||
|
showLeftPanel: false
|
||||||
|
},
|
||||||
|
render: (args) => ({
|
||||||
|
components: { AssetBrowserModal },
|
||||||
|
setup() {
|
||||||
|
const onAssetSelect = (asset: any) => {
|
||||||
|
console.log('Selected asset:', asset)
|
||||||
|
}
|
||||||
|
const onClose = () => {
|
||||||
|
console.log('Modal closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...args, onAssetSelect, onClose, assets: mockAssets }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||||
|
<AssetBrowserModal
|
||||||
|
:node-type="nodeType"
|
||||||
|
:input-name="inputName"
|
||||||
|
:show-left-panel="showLeftPanel"
|
||||||
|
:assets="assets"
|
||||||
|
@asset-select="onAssetSelect"
|
||||||
|
@close="onClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Modal with left panel explicitly disabled via showLeftPanel=false.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/platform/assets/components/AssetBrowserModal.vue
Normal file
95
src/platform/assets/components/AssetBrowserModal.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<BaseModalLayout
|
||||||
|
data-component-id="AssetBrowserModal"
|
||||||
|
class="size-full max-h-full max-w-full min-w-0"
|
||||||
|
:content-title="contentTitle"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<template v-if="shouldShowLeftPanel" #leftPanel>
|
||||||
|
<LeftSidePanel
|
||||||
|
v-model="selectedCategory"
|
||||||
|
data-component-id="AssetBrowserModal-LeftSidePanel"
|
||||||
|
:nav-items="availableCategories"
|
||||||
|
>
|
||||||
|
<template #header-icon>
|
||||||
|
<i-lucide:folder class="size-4" />
|
||||||
|
</template>
|
||||||
|
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
|
||||||
|
</LeftSidePanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #header>
|
||||||
|
<SearchBox
|
||||||
|
v-model="searchQuery"
|
||||||
|
size="lg"
|
||||||
|
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
|
||||||
|
class="max-w-96"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<AssetGrid
|
||||||
|
:assets="filteredAssets"
|
||||||
|
@asset-select="handleAssetSelectAndEmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BaseModalLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import SearchBox from '@/components/input/SearchBox.vue'
|
||||||
|
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||||
|
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||||
|
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nodeType?: string
|
||||||
|
inputName?: string
|
||||||
|
onSelect?: (assetPath: string) => void
|
||||||
|
onClose?: () => void
|
||||||
|
showLeftPanel?: boolean
|
||||||
|
assets?: AssetItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'asset-select': [asset: AssetDisplayItem]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Use AssetBrowser composable for all business logic
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
selectedCategory,
|
||||||
|
availableCategories,
|
||||||
|
contentTitle,
|
||||||
|
filteredAssets,
|
||||||
|
selectAsset
|
||||||
|
} = useAssetBrowser(props.assets)
|
||||||
|
|
||||||
|
// Dialog controls panel visibility via prop
|
||||||
|
const shouldShowLeftPanel = computed(() => {
|
||||||
|
return props.showLeftPanel ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle close button - call both the prop callback and emit the event
|
||||||
|
const handleClose = () => {
|
||||||
|
props.onClose?.()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle asset selection and emit to parent
|
||||||
|
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
|
||||||
|
selectAsset(asset) // This logs the selection for dev mode
|
||||||
|
emit('asset-select', asset) // Emit the full asset object
|
||||||
|
|
||||||
|
// Call prop callback if provided
|
||||||
|
if (props.onSelect) {
|
||||||
|
props.onSelect(asset.name) // Use asset name as the asset path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
182
src/platform/assets/components/AssetCard.stories.ts
Normal file
182
src/platform/assets/components/AssetCard.stories.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
|
||||||
|
|
||||||
|
// Use the first mock asset as base and transform it to display format
|
||||||
|
const baseAsset = mockAssets[0]
|
||||||
|
const createAssetData = (
|
||||||
|
overrides: Partial<AssetDisplayItem> = {}
|
||||||
|
): AssetDisplayItem => ({
|
||||||
|
...baseAsset,
|
||||||
|
description:
|
||||||
|
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
|
||||||
|
formattedSize: '2.1 GB',
|
||||||
|
badges: [
|
||||||
|
{ label: 'checkpoints', type: 'type' },
|
||||||
|
{ label: '2.1 GB', type: 'size' }
|
||||||
|
],
|
||||||
|
stats: {
|
||||||
|
formattedDate: '3/15/25',
|
||||||
|
downloadCount: '1.8k',
|
||||||
|
stars: '4.2k'
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
const meta: Meta<typeof AssetCard> = {
|
||||||
|
title: 'Platform/Assets/AssetCard',
|
||||||
|
component: AssetCard,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered'
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
() => ({
|
||||||
|
template:
|
||||||
|
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900"><story /></div>'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Interactive: Story = {
|
||||||
|
args: {
|
||||||
|
asset: createAssetData(),
|
||||||
|
interactive: true
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
() => ({
|
||||||
|
template:
|
||||||
|
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Default AssetCard with complete data including badges and all stats.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NonInteractive: Story = {
|
||||||
|
args: {
|
||||||
|
asset: createAssetData(),
|
||||||
|
interactive: false
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
() => ({
|
||||||
|
template:
|
||||||
|
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'AssetCard in non-interactive mode - renders as div without button semantics.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EdgeCases: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { AssetCard },
|
||||||
|
setup() {
|
||||||
|
const edgeCases = [
|
||||||
|
// Default case for comparison
|
||||||
|
createAssetData({
|
||||||
|
name: 'Complete Data',
|
||||||
|
description: 'Asset with all data present for comparison'
|
||||||
|
}),
|
||||||
|
// No badges
|
||||||
|
createAssetData({
|
||||||
|
id: 'no-badges',
|
||||||
|
name: 'No Badges',
|
||||||
|
description: 'Testing graceful handling when badges are not provided',
|
||||||
|
badges: []
|
||||||
|
}),
|
||||||
|
// No stars
|
||||||
|
createAssetData({
|
||||||
|
id: 'no-stars',
|
||||||
|
name: 'No Stars',
|
||||||
|
description: 'Testing missing stars data gracefully',
|
||||||
|
stats: {
|
||||||
|
downloadCount: '1.8k',
|
||||||
|
formattedDate: '3/15/25'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// No downloads
|
||||||
|
createAssetData({
|
||||||
|
id: 'no-downloads',
|
||||||
|
name: 'No Downloads',
|
||||||
|
description: 'Testing missing downloads data gracefully',
|
||||||
|
stats: {
|
||||||
|
stars: '4.2k',
|
||||||
|
formattedDate: '3/15/25'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// No date
|
||||||
|
createAssetData({
|
||||||
|
id: 'no-date',
|
||||||
|
name: 'No Date',
|
||||||
|
description: 'Testing missing date data gracefully',
|
||||||
|
stats: {
|
||||||
|
stars: '4.2k',
|
||||||
|
downloadCount: '1.8k'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// No stats at all
|
||||||
|
createAssetData({
|
||||||
|
id: 'no-stats',
|
||||||
|
name: 'No Stats',
|
||||||
|
description: 'Testing when all stats are missing',
|
||||||
|
stats: {}
|
||||||
|
}),
|
||||||
|
// Long description
|
||||||
|
createAssetData({
|
||||||
|
id: 'long-desc',
|
||||||
|
name: 'Long Description',
|
||||||
|
description:
|
||||||
|
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
|
||||||
|
}),
|
||||||
|
// Minimal data
|
||||||
|
createAssetData({
|
||||||
|
id: 'minimal',
|
||||||
|
name: 'Minimal',
|
||||||
|
description: 'Basic model',
|
||||||
|
tags: ['models'],
|
||||||
|
badges: [],
|
||||||
|
stats: {}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
return { edgeCases }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-gray-900">
|
||||||
|
<AssetCard
|
||||||
|
v-for="asset in edgeCases"
|
||||||
|
:key="asset.id"
|
||||||
|
:asset="asset"
|
||||||
|
:interactive="true"
|
||||||
|
@select="(asset) => console.log('Selected:', asset)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'All AssetCard edge cases in a grid layout to test graceful handling of missing data, badges, stats, and long descriptions.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/platform/assets/components/AssetCard.vue
Normal file
111
src/platform/assets/components/AssetCard.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="interactive ? 'button' : 'div'"
|
||||||
|
data-component-id="AssetCard"
|
||||||
|
:data-asset-id="asset.id"
|
||||||
|
v-bind="elementProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
// Base layout and container styles (always applied)
|
||||||
|
'rounded-xl overflow-hidden transition-all duration-200',
|
||||||
|
// Button-specific styles
|
||||||
|
interactive && [
|
||||||
|
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||||
|
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
|
||||||
|
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
|
||||||
|
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
|
||||||
|
],
|
||||||
|
// Div-specific styles
|
||||||
|
!interactive && [
|
||||||
|
'bg-ivory-100 border border-gray-300',
|
||||||
|
'dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="interactive && $emit('select', asset)"
|
||||||
|
@keydown.enter="interactive && $emit('select', asset)"
|
||||||
|
>
|
||||||
|
<div class="relative w-full aspect-square overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="w-full h-full bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600 flex items-center justify-center"
|
||||||
|
></div>
|
||||||
|
<AssetBadgeGroup :badges="asset.badges" />
|
||||||
|
</div>
|
||||||
|
<div class="p-4 h-32 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
|
||||||
|
'text-slate-800',
|
||||||
|
'dark-theme:text-white'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ asset.name }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||||
|
'text-stone-300',
|
||||||
|
'dark-theme:text-stone-200'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:title="asset.description"
|
||||||
|
>
|
||||||
|
{{ asset.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex gap-4 text-xs',
|
||||||
|
'text-stone-400',
|
||||||
|
'dark-theme:text-stone-300'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span v-if="asset.stats.stars" class="flex items-center gap-1">
|
||||||
|
<i-lucide:star class="size-3" />
|
||||||
|
{{ asset.stats.stars }}
|
||||||
|
</span>
|
||||||
|
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
|
||||||
|
<i-lucide:download class="size-3" />
|
||||||
|
{{ asset.stats.downloadCount }}
|
||||||
|
</span>
|
||||||
|
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
|
||||||
|
<i-lucide:clock class="size-3" />
|
||||||
|
{{ asset.stats.formattedDate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
asset: AssetDisplayItem
|
||||||
|
interactive?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const elementProps = computed(() =>
|
||||||
|
props.interactive
|
||||||
|
? {
|
||||||
|
type: 'button',
|
||||||
|
'aria-label': `Select asset ${props.asset.name}`
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
select: [asset: AssetDisplayItem]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
102
src/platform/assets/components/AssetFilterBar.vue
Normal file
102
src/platform/assets/components/AssetFilterBar.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="containerClasses" data-component-id="asset-filter-bar">
|
||||||
|
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
||||||
|
<MultiSelect
|
||||||
|
v-model="fileFormats"
|
||||||
|
label="File formats"
|
||||||
|
:options="fileFormatOptions"
|
||||||
|
:class="selectClasses"
|
||||||
|
data-component-id="asset-filter-file-formats"
|
||||||
|
@update:model-value="handleFilterChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
v-model="baseModels"
|
||||||
|
label="Base models"
|
||||||
|
:options="baseModelOptions"
|
||||||
|
:class="selectClasses"
|
||||||
|
data-component-id="asset-filter-base-models"
|
||||||
|
@update:model-value="handleFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
|
||||||
|
<SingleSelect
|
||||||
|
v-model="sortBy"
|
||||||
|
label="Sort by"
|
||||||
|
:options="sortOptions"
|
||||||
|
:class="selectClasses"
|
||||||
|
data-component-id="asset-filter-sort"
|
||||||
|
@update:model-value="handleFilterChange"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i-lucide:arrow-up-down class="size-3" />
|
||||||
|
</template>
|
||||||
|
</SingleSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||||
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
|
import type { SelectOption } from '@/components/input/types'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
fileFormats: string[]
|
||||||
|
baseModels: string[]
|
||||||
|
sortBy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileFormats = ref<SelectOption[]>([])
|
||||||
|
const baseModels = ref<SelectOption[]>([])
|
||||||
|
const sortBy = ref('name-asc')
|
||||||
|
|
||||||
|
// TODO: Make fileFormatOptions configurable via props or assetService
|
||||||
|
// Should support dynamic file formats based on available assets or server capabilities
|
||||||
|
const fileFormatOptions = [
|
||||||
|
{ name: '.ckpt', value: 'ckpt' },
|
||||||
|
{ name: '.safetensors', value: 'safetensors' },
|
||||||
|
{ name: '.pt', value: 'pt' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// TODO: Make baseModelOptions configurable via props or assetService
|
||||||
|
// Should support dynamic base models based on available assets or server detection
|
||||||
|
const baseModelOptions = [
|
||||||
|
{ name: 'SD 1.5', value: 'sd15' },
|
||||||
|
{ name: 'SD XL', value: 'sdxl' },
|
||||||
|
{ name: 'SD 3.5', value: 'sd35' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// TODO: Make sortOptions configurable via props
|
||||||
|
// Different asset types might need different sorting options
|
||||||
|
const sortOptions = [
|
||||||
|
{ name: 'A-Z', value: 'name-asc' },
|
||||||
|
{ name: 'Z-A', value: 'name-desc' },
|
||||||
|
{ name: 'Recent', value: 'recent' },
|
||||||
|
{ name: 'Popular', value: 'popular' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
filterChange: [filters: FilterState]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerClasses = cn(
|
||||||
|
'flex gap-4 items-center justify-between',
|
||||||
|
'px-6 pt-2 pb-6'
|
||||||
|
)
|
||||||
|
const leftSideClasses = cn('flex gap-4 items-center')
|
||||||
|
const rightSideClasses = cn('flex items-center')
|
||||||
|
const selectClasses = cn('min-w-32')
|
||||||
|
|
||||||
|
function handleFilterChange() {
|
||||||
|
emit('filterChange', {
|
||||||
|
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||||
|
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||||
|
sortBy: sortBy.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
70
src/platform/assets/components/AssetGrid.vue
Normal file
70
src/platform/assets/components/AssetGrid.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-component-id="AssetGrid"
|
||||||
|
:style="gridStyle"
|
||||||
|
role="grid"
|
||||||
|
aria-label="Asset collection"
|
||||||
|
:aria-rowcount="-1"
|
||||||
|
:aria-colcount="-1"
|
||||||
|
:aria-setsize="assets.length"
|
||||||
|
>
|
||||||
|
<AssetCard
|
||||||
|
v-for="asset in assets"
|
||||||
|
:key="asset.id"
|
||||||
|
:asset="asset"
|
||||||
|
:interactive="true"
|
||||||
|
role="gridcell"
|
||||||
|
@select="$emit('assetSelect', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-if="assets.length === 0"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'col-span-full flex flex-col items-center justify-center py-16',
|
||||||
|
'text-stone-300 dark-theme:text-stone-200'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i-lucide:search class="size-10 mb-4" />
|
||||||
|
<h3 class="text-lg font-medium mb-2">
|
||||||
|
{{ $t('assetBrowser.noAssetsFound') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="col-span-full flex items-center justify-center py-16"
|
||||||
|
>
|
||||||
|
<i-lucide:loader
|
||||||
|
:class="
|
||||||
|
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { createGridStyle } from '@/utils/gridUtil'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
assets: AssetDisplayItem[]
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
assetSelect: [asset: AssetDisplayItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Use same grid style as BaseModalLayout
|
||||||
|
const gridStyle = computed(() => createGridStyle())
|
||||||
|
</script>
|
||||||
188
src/platform/assets/composables/useAssetBrowser.ts
Normal file
188
src/platform/assets/composables/useAssetBrowser.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import {
|
||||||
|
getAssetBaseModel,
|
||||||
|
getAssetDescription
|
||||||
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
import { formatSize } from '@/utils/formatUtil'
|
||||||
|
|
||||||
|
type AssetBadge = {
|
||||||
|
label: string
|
||||||
|
type: 'type' | 'base' | 'size'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display properties for transformed assets
|
||||||
|
export interface AssetDisplayItem extends AssetItem {
|
||||||
|
description: string
|
||||||
|
formattedSize: string
|
||||||
|
badges: AssetBadge[]
|
||||||
|
stats: {
|
||||||
|
formattedDate?: string
|
||||||
|
downloadCount?: string
|
||||||
|
stars?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset Browser composable
|
||||||
|
* Manages search, filtering, asset transformation and selection logic
|
||||||
|
*/
|
||||||
|
export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||||
|
// State
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedCategory = ref('all')
|
||||||
|
const sortBy = ref('name')
|
||||||
|
|
||||||
|
// Transform API asset to display asset
|
||||||
|
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
||||||
|
// Extract description from metadata or create from tags
|
||||||
|
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||||
|
const description =
|
||||||
|
getAssetDescription(asset) ||
|
||||||
|
`${typeTag || t('assetBrowser.unknown')} model`
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
const formattedSize = formatSize(asset.size)
|
||||||
|
|
||||||
|
// Create badges from tags and metadata
|
||||||
|
const badges: AssetBadge[] = []
|
||||||
|
|
||||||
|
// Type badge from non-root tag
|
||||||
|
if (typeTag) {
|
||||||
|
badges.push({ label: typeTag, type: 'type' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base model badge from metadata
|
||||||
|
const baseModel = getAssetBaseModel(asset)
|
||||||
|
if (baseModel) {
|
||||||
|
badges.push({
|
||||||
|
label: baseModel,
|
||||||
|
type: 'base'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size badge
|
||||||
|
badges.push({ label: formattedSize, type: 'size' })
|
||||||
|
|
||||||
|
// Create display stats from API data
|
||||||
|
const stats = {
|
||||||
|
formattedDate: new Date(asset.created_at).toLocaleDateString(),
|
||||||
|
downloadCount: undefined, // Not available in API
|
||||||
|
stars: undefined // Not available in API
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...asset,
|
||||||
|
description,
|
||||||
|
formattedSize,
|
||||||
|
badges,
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract available categories from assets
|
||||||
|
const availableCategories = computed(() => {
|
||||||
|
const categorySet = new Set<string>()
|
||||||
|
|
||||||
|
assets.forEach((asset) => {
|
||||||
|
// Second tag is the category (after 'models' root tag)
|
||||||
|
if (asset.tags.length > 1 && asset.tags[0] === 'models') {
|
||||||
|
categorySet.add(asset.tags[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
label: t('assetBrowser.allModels'),
|
||||||
|
icon: 'icon-[lucide--folder]'
|
||||||
|
},
|
||||||
|
...Array.from(categorySet)
|
||||||
|
.sort()
|
||||||
|
.map((category) => ({
|
||||||
|
id: category,
|
||||||
|
label: category.charAt(0).toUpperCase() + category.slice(1),
|
||||||
|
icon: 'icon-[lucide--package]'
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute content title from selected category
|
||||||
|
const contentTitle = computed(() => {
|
||||||
|
if (selectedCategory.value === 'all') {
|
||||||
|
return t('assetBrowser.allModels')
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = availableCategories.value.find(
|
||||||
|
(cat) => cat.id === selectedCategory.value
|
||||||
|
)
|
||||||
|
return category?.label || t('assetBrowser.assets')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter functions
|
||||||
|
const filterByCategory = (category: string) => (asset: AssetItem) => {
|
||||||
|
if (category === 'all') return true
|
||||||
|
return asset.tags.includes(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterByQuery = (query: string) => (asset: AssetItem) => {
|
||||||
|
if (!query) return true
|
||||||
|
const lowerQuery = query.toLowerCase()
|
||||||
|
const description = getAssetDescription(asset)
|
||||||
|
return (
|
||||||
|
asset.name.toLowerCase().includes(lowerQuery) ||
|
||||||
|
(description && description.toLowerCase().includes(lowerQuery)) ||
|
||||||
|
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed filtered and transformed assets
|
||||||
|
const filteredAssets = computed(() => {
|
||||||
|
const filtered = assets
|
||||||
|
.filter(filterByCategory(selectedCategory.value))
|
||||||
|
.filter(filterByQuery(searchQuery.value))
|
||||||
|
|
||||||
|
// Sort assets
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
switch (sortBy.value) {
|
||||||
|
case 'date':
|
||||||
|
return (
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
)
|
||||||
|
case 'name':
|
||||||
|
default:
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transform to display format
|
||||||
|
return filtered.map(transformAssetForDisplay)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
function selectAsset(asset: AssetDisplayItem): UUID {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('Asset selected:', asset.id, asset.name)
|
||||||
|
}
|
||||||
|
return asset.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
searchQuery,
|
||||||
|
selectedCategory,
|
||||||
|
sortBy,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
availableCategories,
|
||||||
|
contentTitle,
|
||||||
|
filteredAssets,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
selectAsset,
|
||||||
|
transformAssetForDisplay
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/platform/assets/composables/useAssetBrowserDialog.stories.ts
Normal file
203
src/platform/assets/composables/useAssetBrowserDialog.stories.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||||
|
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
|
||||||
|
|
||||||
|
// Component that simulates the useAssetBrowserDialog functionality with working close
|
||||||
|
const DialogDemoComponent = {
|
||||||
|
components: { AssetBrowserModal },
|
||||||
|
setup() {
|
||||||
|
const isDialogOpen = ref(false)
|
||||||
|
const currentNodeType = ref('CheckpointLoaderSimple')
|
||||||
|
const currentInputName = ref('ckpt_name')
|
||||||
|
const currentValue = ref('')
|
||||||
|
|
||||||
|
const handleOpenDialog = (
|
||||||
|
nodeType: string,
|
||||||
|
inputName: string,
|
||||||
|
value = ''
|
||||||
|
) => {
|
||||||
|
currentNodeType.value = nodeType
|
||||||
|
currentInputName.value = inputName
|
||||||
|
currentValue.value = value
|
||||||
|
isDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
isDialogOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssetSelected = (assetPath: string) => {
|
||||||
|
console.log('Asset selected:', assetPath)
|
||||||
|
alert(`Selected asset: ${assetPath}`)
|
||||||
|
isDialogOpen.value = false // Auto-close like the real composable
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenWithCurrentValue = () => {
|
||||||
|
handleOpenDialog(
|
||||||
|
'CheckpointLoaderSimple',
|
||||||
|
'ckpt_name',
|
||||||
|
'realistic_vision_v5.safetensors'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDialogOpen,
|
||||||
|
currentNodeType,
|
||||||
|
currentInputName,
|
||||||
|
currentValue,
|
||||||
|
handleOpenDialog,
|
||||||
|
handleOpenWithCurrentValue,
|
||||||
|
handleCloseDialog,
|
||||||
|
handleAssetSelected,
|
||||||
|
mockAssets
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="relative">
|
||||||
|
<div class="p-8 space-y-4">
|
||||||
|
<h2 class="text-2xl font-bold mb-6">Asset Browser Dialog Demo</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Different Node Types</h3>
|
||||||
|
<div class="flex gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
@click="handleOpenDialog('CheckpointLoaderSimple', 'ckpt_name')"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Browse Checkpoints
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleOpenDialog('VAELoader', 'vae_name')"
|
||||||
|
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Browse VAE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleOpenDialog('ControlNetLoader', 'control_net_name')"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
Browse ControlNet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">With Current Value</h3>
|
||||||
|
<button
|
||||||
|
@click="handleOpenWithCurrentValue"
|
||||||
|
class="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700"
|
||||||
|
>
|
||||||
|
Change Current Model
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
|
Opens with "realistic_vision_v5.safetensors" as current value
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 p-4 bg-gray-100 rounded">
|
||||||
|
<h4 class="font-semibold mb-2">Instructions:</h4>
|
||||||
|
<ul class="text-sm space-y-1">
|
||||||
|
<li>• Click any button to open the Asset Browser dialog</li>
|
||||||
|
<li>• Select an asset to see the callback in action</li>
|
||||||
|
<li>• Check the browser console for logged events</li>
|
||||||
|
<li>• Try toggling the left panel with different asset types</li>
|
||||||
|
<li>• Close button will work properly in this demo</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dialog Modal Overlay -->
|
||||||
|
<div
|
||||||
|
v-if="isDialogOpen"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||||
|
@click.self="handleCloseDialog"
|
||||||
|
>
|
||||||
|
<div class="w-[80vw] h-[80vh] max-w-[80vw] max-h-[80vh] rounded-2xl overflow-hidden">
|
||||||
|
<AssetBrowserModal
|
||||||
|
:assets="mockAssets"
|
||||||
|
:node-type="currentNodeType"
|
||||||
|
:input-name="currentInputName"
|
||||||
|
:current-value="currentValue"
|
||||||
|
@asset-select="handleAssetSelected"
|
||||||
|
@close="handleCloseDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Platform/Assets/useAssetBrowserDialog',
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Demonstrates the AssetBrowserModal functionality as used by the useAssetBrowserDialog composable.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Demo: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { DialogDemoComponent },
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<DialogDemoComponent />
|
||||||
|
|
||||||
|
<!-- Code Example Section -->
|
||||||
|
<div class="p-8 border-t border-gray-200 bg-gray-50">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Code Example</h2>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
This is how you would use the composable in your component:
|
||||||
|
</p>
|
||||||
|
<div class="bg-white p-4 rounded-lg border shadow-sm">
|
||||||
|
<pre><code class="text-sm">import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
setup() {
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
|
||||||
|
const openBrowser = () => {
|
||||||
|
assetBrowserDialog.show({
|
||||||
|
nodeType: 'CheckpointLoaderSimple',
|
||||||
|
inputName: 'ckpt_name',
|
||||||
|
currentValue: '',
|
||||||
|
onAssetSelected: (assetPath) => {
|
||||||
|
console.log('Selected:', assetPath)
|
||||||
|
// Update your component state
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { openBrowser }
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>💡 Try it:</strong> Use the interactive buttons above to see this code in action!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/platform/assets/composables/useAssetBrowserDialog.ts
Normal file
66
src/platform/assets/composables/useAssetBrowserDialog.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
|
interface AssetBrowserDialogProps {
|
||||||
|
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
|
||||||
|
nodeType: string
|
||||||
|
/** Widget input name (e.g., 'ckpt_name') */
|
||||||
|
inputName: string
|
||||||
|
/** Current selected asset value */
|
||||||
|
currentValue?: string
|
||||||
|
/** Callback for when an asset is selected */
|
||||||
|
onAssetSelected?: (assetPath: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAssetBrowserDialog = () => {
|
||||||
|
const dialogStore = useDialogStore()
|
||||||
|
const dialogKey = 'global-asset-browser'
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
dialogStore.closeDialog({ key: dialogKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(props: AssetBrowserDialogProps) {
|
||||||
|
const handleAssetSelected = (assetPath: string) => {
|
||||||
|
props.onAssetSelected?.(assetPath)
|
||||||
|
hide() // Auto-close on selection
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default dialog configuration for AssetBrowserModal
|
||||||
|
const dialogComponentProps = {
|
||||||
|
headless: true,
|
||||||
|
modal: true,
|
||||||
|
closable: false,
|
||||||
|
pt: {
|
||||||
|
root: {
|
||||||
|
class: 'rounded-2xl overflow-hidden'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
class: 'p-0 hidden'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
class: 'p-0 m-0 h-full w-full'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogStore.showDialog({
|
||||||
|
key: dialogKey,
|
||||||
|
component: AssetBrowserModal,
|
||||||
|
props: {
|
||||||
|
nodeType: props.nodeType,
|
||||||
|
inputName: props.inputName,
|
||||||
|
currentValue: props.currentValue,
|
||||||
|
onSelect: handleAssetSelected,
|
||||||
|
onClose: handleClose
|
||||||
|
},
|
||||||
|
dialogComponentProps
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show, hide }
|
||||||
|
}
|
||||||
56
src/platform/assets/composables/useAssetFilterOptions.ts
Normal file
56
src/platform/assets/composables/useAssetFilterOptions.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { uniqWith } from 'es-toolkit'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { SelectOption } from '@/components/input/types'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable that extracts available filter options from asset data
|
||||||
|
* Provides reactive computed properties for file formats and base models
|
||||||
|
*/
|
||||||
|
export function useAssetFilterOptions(assets: AssetItem[] = []) {
|
||||||
|
/**
|
||||||
|
* Extract unique file formats from asset names
|
||||||
|
* Returns sorted SelectOption array with extensions
|
||||||
|
*/
|
||||||
|
const availableFileFormats = computed<SelectOption[]>(() => {
|
||||||
|
const extensions = assets
|
||||||
|
.map((asset) => {
|
||||||
|
const extension = asset.name.split('.').pop()
|
||||||
|
return extension && extension !== asset.name ? extension : null
|
||||||
|
})
|
||||||
|
.filter((extension): extension is string => extension !== null)
|
||||||
|
|
||||||
|
const uniqueExtensions = uniqWith(extensions, (a, b) => a === b)
|
||||||
|
|
||||||
|
return uniqueExtensions.sort().map((format) => ({
|
||||||
|
name: `.${format}`,
|
||||||
|
value: format
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract unique base models from asset user metadata
|
||||||
|
* Returns sorted SelectOption array with base model names
|
||||||
|
*/
|
||||||
|
const availableBaseModels = computed<SelectOption[]>(() => {
|
||||||
|
const models = assets
|
||||||
|
.map((asset) => asset.user_metadata?.base_model)
|
||||||
|
.filter(
|
||||||
|
(baseModel): baseModel is string =>
|
||||||
|
baseModel !== undefined && typeof baseModel === 'string'
|
||||||
|
)
|
||||||
|
|
||||||
|
const uniqueModels = uniqWith(models, (a, b) => a === b)
|
||||||
|
|
||||||
|
return uniqueModels.sort().map((model) => ({
|
||||||
|
name: model,
|
||||||
|
value: model
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableFileFormats,
|
||||||
|
availableBaseModels
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/platform/assets/fixtures/ui-mock-assets.ts
Normal file
128
src/platform/assets/fixtures/ui-mock-assets.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
// 🎭 OBVIOUSLY FAKE MOCK DATA - DO NOT USE IN PRODUCTION! 🎭
|
||||||
|
const fakeFunnyModelNames = [
|
||||||
|
'🎯_totally_real_model_v420.69',
|
||||||
|
'🚀_definitely_not_fake_v999',
|
||||||
|
'🎪_super_legit_checkpoint_pro_max',
|
||||||
|
'🦄_unicorn_dreams_totally_real.model',
|
||||||
|
'🍕_pizza_generator_supreme',
|
||||||
|
'🎸_rock_star_fake_data_v1337',
|
||||||
|
'🌮_taco_tuesday_model_deluxe',
|
||||||
|
'🦖_dino_nugget_generator_v3',
|
||||||
|
'🎮_gamer_fuel_checkpoint_xl',
|
||||||
|
'🍄_mushroom_kingdom_diffusion',
|
||||||
|
'🏴☠️_pirate_treasure_model_arr',
|
||||||
|
'🦋_butterfly_effect_generator',
|
||||||
|
'🎺_jazz_hands_checkpoint_pro',
|
||||||
|
'🥨_pretzel_logic_model_v2',
|
||||||
|
'🌙_midnight_snack_generator',
|
||||||
|
'🎭_drama_llama_checkpoint',
|
||||||
|
'🧙♀️_wizard_hat_diffusion_xl',
|
||||||
|
'🎪_circus_peanut_model_v4',
|
||||||
|
'🦒_giraffe_neck_generator',
|
||||||
|
'🎲_random_stuff_checkpoint_max'
|
||||||
|
]
|
||||||
|
|
||||||
|
const obviouslyFakeDescriptions = [
|
||||||
|
'⚠️ FAKE DATA: Generates 100% authentic fake images with premium mock quality',
|
||||||
|
'🎭 MOCK ALERT: This totally real model creates absolutely genuine fake content',
|
||||||
|
'🚨 NOT REAL: Professional-grade fake imagery for your mock data needs',
|
||||||
|
'🎪 DEMO ONLY: Circus-quality fake generation with extra mock seasoning',
|
||||||
|
'🍕 FAKE FOOD: Generates delicious fake pizzas (not edible in reality)',
|
||||||
|
"🎸 MOCK ROCK: Creates fake rock stars who definitely don't exist",
|
||||||
|
'🌮 TACO FAKERY: Tuesday-themed fake tacos for your mock appetite',
|
||||||
|
'🦖 PREHISTORIC FAKE: Generates extinct fake dinosaurs for demo purposes',
|
||||||
|
'🎮 FAKE GAMING: Level up your mock data with obviously fake content',
|
||||||
|
'🍄 FUNGI FICTION: Magical fake mushrooms from the demo dimension',
|
||||||
|
'🏴☠️ FAKE TREASURE: Arr! This be mock data for ye demo needs, matey!',
|
||||||
|
'🦋 DEMO EFFECT: Small fake changes create big mock differences',
|
||||||
|
'🎺 JAZZ FAKERY: Smooth fake jazz for your mock listening pleasure',
|
||||||
|
'🥨 MOCK LOGIC: Twisted fake reasoning for your demo requirements',
|
||||||
|
'🌙 MIDNIGHT MOCK: Late-night fake snacks for your demo hunger',
|
||||||
|
'🎭 FAKE DRAMA: Over-the-top mock emotions for demo entertainment',
|
||||||
|
'🧙♀️ WIZARD MOCK: Magically fake spells cast with demo ingredients',
|
||||||
|
'🎪 CIRCUS FAKE: Big top mock entertainment under the demo tent',
|
||||||
|
'🦒 TALL FAKE: Reaches new heights of obviously fake content',
|
||||||
|
'🎲 RANDOM MOCK: Generates random fake stuff for your demo pleasure'
|
||||||
|
]
|
||||||
|
|
||||||
|
// API-compliant tag structure: first tag must be root (models/input/output), second is category
|
||||||
|
const modelCategories = ['checkpoints', 'loras', 'embeddings', 'vae']
|
||||||
|
const baseModels = ['sd15', 'sdxl', 'sd35']
|
||||||
|
const fileExtensions = ['.safetensors', '.ckpt', '.pt']
|
||||||
|
const mimeTypes = [
|
||||||
|
'application/octet-stream',
|
||||||
|
'application/x-pytorch',
|
||||||
|
'application/x-safetensors'
|
||||||
|
]
|
||||||
|
|
||||||
|
function getRandomElement<T>(array: T[]): T {
|
||||||
|
return array[Math.floor(Math.random() * array.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomNumber(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomISODate(): string {
|
||||||
|
const start = new Date('2024-01-01').getTime()
|
||||||
|
const end = new Date('2024-12-31').getTime()
|
||||||
|
const randomTime = start + Math.random() * (end - start)
|
||||||
|
return new Date(randomTime).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFakeAssetHash(): string {
|
||||||
|
const chars = '0123456789abcdef'
|
||||||
|
let hash = 'blake3:'
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
hash += chars[Math.floor(Math.random() * chars.length)]
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎭 CREATES OBVIOUSLY FAKE ASSETS FOR DEMO/TEST PURPOSES ONLY! 🎭
|
||||||
|
export function createMockAssets(count: number = 20): AssetItem[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const category = getRandomElement(modelCategories)
|
||||||
|
const baseModel = getRandomElement(baseModels)
|
||||||
|
const extension = getRandomElement(fileExtensions)
|
||||||
|
const mimeType = getRandomElement(mimeTypes)
|
||||||
|
const sizeInBytes = getRandomNumber(
|
||||||
|
500 * 1024 * 1024,
|
||||||
|
8 * 1024 * 1024 * 1024
|
||||||
|
) // 500MB to 8GB
|
||||||
|
const createdAt = getRandomISODate()
|
||||||
|
const updatedAt = createdAt
|
||||||
|
const lastAccessTime = getRandomISODate()
|
||||||
|
|
||||||
|
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
|
||||||
|
name: fakeFileName,
|
||||||
|
asset_hash: generateFakeAssetHash(),
|
||||||
|
size: sizeInBytes,
|
||||||
|
mime_type: mimeType,
|
||||||
|
tags: [
|
||||||
|
'models', // Root tag (required first)
|
||||||
|
category, // Category tag (required second for models)
|
||||||
|
'fake-data', // Obviously fake tag
|
||||||
|
...(Math.random() > 0.5 ? ['demo-mode'] : ['test-only']),
|
||||||
|
...(Math.random() > 0.7 ? ['obviously-mock'] : [])
|
||||||
|
],
|
||||||
|
preview_url: `/api/assets/mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake/content`,
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
last_access_time: lastAccessTime,
|
||||||
|
user_metadata: {
|
||||||
|
description: obviouslyFakeDescriptions[index],
|
||||||
|
base_model: baseModel,
|
||||||
|
original_name: fakeFunnyModelNames[index],
|
||||||
|
warning: '🚨 THIS IS FAKE DEMO DATA - NOT A REAL MODEL! 🚨'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockAssets = createMockAssets(20)
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
// Zod schemas for asset API validation
|
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
|
||||||
const zAsset = z.object({
|
const zAsset = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
tags: z.array(z.string()),
|
asset_hash: z.string(),
|
||||||
size: z.number(),
|
size: z.number(),
|
||||||
created_at: z.string().optional()
|
mime_type: z.string(),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
preview_url: z.string().optional(),
|
||||||
|
created_at: z.string(),
|
||||||
|
updated_at: z.string(),
|
||||||
|
last_access_time: z.string(),
|
||||||
|
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||||
|
preview_id: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
const zAssetResponse = z.object({
|
const zAssetResponse = z.object({
|
||||||
@@ -20,19 +27,22 @@ const zModelFolder = z.object({
|
|||||||
folders: z.array(z.string())
|
folders: z.array(z.string())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Zod schema for ModelFile to align with interface
|
||||||
|
const zModelFile = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
pathIndex: z.number()
|
||||||
|
})
|
||||||
|
|
||||||
// Export schemas following repository patterns
|
// Export schemas following repository patterns
|
||||||
export const assetResponseSchema = zAssetResponse
|
export const assetResponseSchema = zAssetResponse
|
||||||
|
|
||||||
// Export types derived from Zod schemas
|
// Export types derived from Zod schemas
|
||||||
|
export type AssetItem = z.infer<typeof zAsset>
|
||||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||||
|
export type ModelFile = z.infer<typeof zModelFile>
|
||||||
|
|
||||||
// Common interfaces for API responses
|
// Legacy interface for backward compatibility (now aligned with Zod schema)
|
||||||
export interface ModelFile {
|
|
||||||
name: string
|
|
||||||
pathIndex: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelFolderInfo {
|
export interface ModelFolderInfo {
|
||||||
name: string
|
name: string
|
||||||
folders: string[]
|
folders: string[]
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function createAssetService() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Blacklist directories we don't want to show
|
// Blacklist directories we don't want to show
|
||||||
const blacklistedDirectories = ['configs']
|
const blacklistedDirectories = new Set(['configs'])
|
||||||
|
|
||||||
// Extract directory names from assets that actually exist, exclude missing assets
|
// Extract directory names from assets that actually exist, exclude missing assets
|
||||||
const discoveredFolders = new Set<string>(
|
const discoveredFolders = new Set<string>(
|
||||||
@@ -75,7 +75,7 @@ function createAssetService() {
|
|||||||
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
|
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
|
||||||
?.flatMap((asset) => asset.tags)
|
?.flatMap((asset) => asset.tags)
|
||||||
?.filter(
|
?.filter(
|
||||||
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
|
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
|
||||||
) ?? []
|
) ?? []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
27
src/platform/assets/utils/assetMetadataUtils.ts
Normal file
27
src/platform/assets/utils/assetMetadataUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe utilities for extracting metadata from assets
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts string description from asset metadata
|
||||||
|
* @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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts string base_model from asset metadata
|
||||||
|
* @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
|
||||||
|
}
|
||||||
@@ -6,6 +6,16 @@ import { iconCollection } from './build/customIconCollection'
|
|||||||
export default {
|
export default {
|
||||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||||
|
|
||||||
|
safelist: [
|
||||||
|
'icon-[lucide--folder]',
|
||||||
|
'icon-[lucide--package]',
|
||||||
|
'icon-[lucide--image]',
|
||||||
|
'icon-[lucide--video]',
|
||||||
|
'icon-[lucide--box]',
|
||||||
|
'icon-[lucide--audio-waveform]',
|
||||||
|
'icon-[lucide--message-circle]'
|
||||||
|
],
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
addDynamicIconSelectors({
|
addDynamicIconSelectors({
|
||||||
iconSets: {
|
iconSets: {
|
||||||
|
|||||||
304
tests-ui/platform/assets/components/AssetBrowserModal.test.ts
Normal file
304
tests-ui/platform/assets/components/AssetBrowserModal.test.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
// Mock external dependencies with minimal functionality needed for business logic tests
|
||||||
|
vi.mock('@/components/input/SearchBox.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'SearchBox',
|
||||||
|
props: ['modelValue', 'size', 'placeholder', 'class'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: `
|
||||||
|
<input
|
||||||
|
:value="modelValue"
|
||||||
|
@input="$emit('update:modelValue', $event.target.value)"
|
||||||
|
data-testid="search-box"
|
||||||
|
/>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'BaseModalLayout',
|
||||||
|
props: ['contentTitle'],
|
||||||
|
emits: ['close'],
|
||||||
|
template: `
|
||||||
|
<div data-testid="base-modal-layout">
|
||||||
|
<div v-if="$slots.leftPanel" data-testid="left-panel">
|
||||||
|
<slot name="leftPanel" />
|
||||||
|
</div>
|
||||||
|
<div data-testid="header">
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<div data-testid="content">
|
||||||
|
<slot name="content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'LeftSidePanel',
|
||||||
|
props: ['modelValue', 'navItems'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: `
|
||||||
|
<div data-testid="left-side-panel">
|
||||||
|
<button
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.id"
|
||||||
|
@click="$emit('update:modelValue', item.id)"
|
||||||
|
:data-testid="'nav-item-' + item.id"
|
||||||
|
:class="{ active: modelValue === item.id }"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'AssetGrid',
|
||||||
|
props: ['assets'],
|
||||||
|
emits: ['asset-select'],
|
||||||
|
template: `
|
||||||
|
<div data-testid="asset-grid">
|
||||||
|
<div
|
||||||
|
v-for="asset in assets"
|
||||||
|
:key="asset.id"
|
||||||
|
@click="$emit('asset-select', asset)"
|
||||||
|
:data-testid="'asset-' + asset.id"
|
||||||
|
class="asset-card"
|
||||||
|
>
|
||||||
|
{{ asset.name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="assets.length === 0" data-testid="empty-state">
|
||||||
|
No assets found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AssetBrowserModal', () => {
|
||||||
|
const createTestAsset = (
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
category: string
|
||||||
|
): AssetItem => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
asset_hash: `blake3:${id.padEnd(64, '0')}`,
|
||||||
|
size: 1024000,
|
||||||
|
mime_type: 'application/octet-stream',
|
||||||
|
tags: ['models', category, 'test'],
|
||||||
|
preview_url: `/api/assets/${id}/content`,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
last_access_time: '2024-01-01T00:00:00Z',
|
||||||
|
user_metadata: {
|
||||||
|
description: `Test ${name}`,
|
||||||
|
base_model: 'sd15'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const createWrapper = (
|
||||||
|
assets: AssetItem[] = [],
|
||||||
|
props: Record<string, unknown> = {}
|
||||||
|
) => {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
|
||||||
|
return mount(AssetBrowserModal, {
|
||||||
|
props: {
|
||||||
|
assets: assets,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [pinia],
|
||||||
|
stubs: {
|
||||||
|
'i-lucide:folder': {
|
||||||
|
template: '<div data-testid="folder-icon"></div>'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mocks: {
|
||||||
|
$t: (key: string) => key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Search Functionality', () => {
|
||||||
|
it('filters assets when search query changes', async () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
|
||||||
|
createTestAsset('asset2', 'Checkpoint Model B', 'checkpoints'),
|
||||||
|
createTestAsset('asset3', 'LoRA Model C', 'loras')
|
||||||
|
]
|
||||||
|
const wrapper = createWrapper(assets)
|
||||||
|
|
||||||
|
const searchBox = wrapper.find('[data-testid="search-box"]')
|
||||||
|
|
||||||
|
// Search for "Checkpoint"
|
||||||
|
await searchBox.setValue('Checkpoint')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Should filter to only checkpoint assets
|
||||||
|
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||||
|
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
||||||
|
|
||||||
|
expect(filteredAssets.length).toBe(2)
|
||||||
|
expect(
|
||||||
|
filteredAssets.every((asset: AssetDisplayItem) =>
|
||||||
|
asset.name.includes('Checkpoint')
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('search is case insensitive', async () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset('asset1', 'LoRA Model C', 'loras'),
|
||||||
|
createTestAsset('asset2', 'Checkpoint Model', 'checkpoints')
|
||||||
|
]
|
||||||
|
const wrapper = createWrapper(assets)
|
||||||
|
|
||||||
|
const searchBox = wrapper.find('[data-testid="search-box"]')
|
||||||
|
|
||||||
|
// Search with different case
|
||||||
|
await searchBox.setValue('lora')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||||
|
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
||||||
|
|
||||||
|
expect(filteredAssets.length).toBe(1)
|
||||||
|
expect(filteredAssets[0].name).toContain('LoRA')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when search has no results', async () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset('asset1', 'Checkpoint Model', 'checkpoints')
|
||||||
|
]
|
||||||
|
const wrapper = createWrapper(assets)
|
||||||
|
|
||||||
|
const searchBox = wrapper.find('[data-testid="search-box"]')
|
||||||
|
|
||||||
|
// Search for something that doesn't exist
|
||||||
|
await searchBox.setValue('nonexistent')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Category Navigation', () => {
|
||||||
|
it('filters assets by selected category', async () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
|
||||||
|
createTestAsset('asset2', 'LoRA Model C', 'loras'),
|
||||||
|
createTestAsset('asset3', 'VAE Model D', 'vae')
|
||||||
|
]
|
||||||
|
const wrapper = createWrapper(assets, { showLeftPanel: true })
|
||||||
|
|
||||||
|
// Wait for Vue reactivity and component mounting
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Check if left panel exists first (since we have multiple categories)
|
||||||
|
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||||
|
expect(leftPanel.exists()).toBe(true)
|
||||||
|
|
||||||
|
// Check if the nav item exists before clicking
|
||||||
|
const lorasNavItem = wrapper.find('[data-testid="nav-item-loras"]')
|
||||||
|
expect(lorasNavItem.exists()).toBe(true)
|
||||||
|
|
||||||
|
// Click the loras category
|
||||||
|
await lorasNavItem.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Should filter to only LoRA assets
|
||||||
|
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||||
|
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
||||||
|
|
||||||
|
expect(filteredAssets.length).toBe(1)
|
||||||
|
expect(filteredAssets[0].name).toContain('LoRA')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Asset Selection', () => {
|
||||||
|
it('emits asset-select event when asset is selected', async () => {
|
||||||
|
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||||
|
const wrapper = createWrapper(assets)
|
||||||
|
|
||||||
|
// Click on first asset
|
||||||
|
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('asset-select')
|
||||||
|
expect(emitted).toBeDefined()
|
||||||
|
expect(emitted).toHaveLength(1)
|
||||||
|
|
||||||
|
const emittedAsset = emitted![0][0] as AssetDisplayItem
|
||||||
|
expect(emittedAsset.id).toBe('asset1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('executes onSelect callback when provided', async () => {
|
||||||
|
const onSelectSpy = vi.fn()
|
||||||
|
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||||
|
const wrapper = createWrapper(assets, { onSelect: onSelectSpy })
|
||||||
|
|
||||||
|
// Click on first asset
|
||||||
|
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
|
||||||
|
|
||||||
|
expect(onSelectSpy).toHaveBeenCalledWith('Test Model')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Left Panel Conditional Logic', () => {
|
||||||
|
it('hides left panel by default when showLeftPanel prop is undefined', () => {
|
||||||
|
const singleCategoryAssets = [
|
||||||
|
createTestAsset('single1', 'Asset 1', 'checkpoints'),
|
||||||
|
createTestAsset('single2', 'Asset 2', 'checkpoints')
|
||||||
|
]
|
||||||
|
const wrapper = createWrapper(singleCategoryAssets)
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows left panel when showLeftPanel prop is explicitly true', () => {
|
||||||
|
const singleCategoryAssets = [
|
||||||
|
createTestAsset('single1', 'Asset 1', 'checkpoints')
|
||||||
|
]
|
||||||
|
|
||||||
|
// Force show even with single category
|
||||||
|
const wrapper = createWrapper(singleCategoryAssets, {
|
||||||
|
showLeftPanel: true
|
||||||
|
})
|
||||||
|
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Force hide even with multiple categories
|
||||||
|
wrapper.unmount()
|
||||||
|
const multiCategoryAssets = [
|
||||||
|
createTestAsset('asset1', 'Checkpoint', 'checkpoints'),
|
||||||
|
createTestAsset('asset2', 'LoRA', 'loras')
|
||||||
|
]
|
||||||
|
const wrapper2 = createWrapper(multiCategoryAssets, {
|
||||||
|
showLeftPanel: false
|
||||||
|
})
|
||||||
|
expect(wrapper2.find('[data-testid="left-panel"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
138
tests-ui/platform/assets/components/AssetFilterBar.test.ts
Normal file
138
tests-ui/platform/assets/components/AssetFilterBar.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
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'
|
||||||
|
|
||||||
|
// Mock components with minimal functionality for business logic testing
|
||||||
|
vi.mock('@/components/input/MultiSelect.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'MultiSelect',
|
||||||
|
props: {
|
||||||
|
modelValue: Array,
|
||||||
|
label: String,
|
||||||
|
options: Array,
|
||||||
|
class: String
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: `
|
||||||
|
<div data-testid="multi-select">
|
||||||
|
<select multiple @change="$emit('update:modelValue', Array.from($event.target.selectedOptions).map(o => ({ name: o.text, value: o.value })))">
|
||||||
|
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||||
|
{{ option.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/input/SingleSelect.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'SingleSelect',
|
||||||
|
props: {
|
||||||
|
modelValue: String,
|
||||||
|
label: String,
|
||||||
|
options: Array,
|
||||||
|
class: String
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: `
|
||||||
|
<div data-testid="single-select">
|
||||||
|
<select @change="$emit('update:modelValue', $event.target.value)">
|
||||||
|
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||||
|
{{ option.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Test factory functions
|
||||||
|
|
||||||
|
describe('AssetFilterBar', () => {
|
||||||
|
describe('Filter State Management', () => {
|
||||||
|
it('maintains correct initial state', () => {
|
||||||
|
const wrapper = mount(AssetFilterBar)
|
||||||
|
|
||||||
|
// Test initial state through component props
|
||||||
|
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
|
||||||
|
const singleSelect = wrapper.findComponent({ name: 'SingleSelect' })
|
||||||
|
|
||||||
|
expect(multiSelects[0].props('modelValue')).toEqual([])
|
||||||
|
expect(multiSelects[1].props('modelValue')).toEqual([])
|
||||||
|
expect(singleSelect.props('modelValue')).toBe('name-asc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles multiple simultaneous filter changes correctly', async () => {
|
||||||
|
const wrapper = mount(AssetFilterBar)
|
||||||
|
|
||||||
|
// Update file formats
|
||||||
|
const fileFormatSelect = wrapper.findAllComponents({
|
||||||
|
name: 'MultiSelect'
|
||||||
|
})[0]
|
||||||
|
await fileFormatSelect.vm.$emit('update:modelValue', [
|
||||||
|
{ name: '.ckpt', value: 'ckpt' },
|
||||||
|
{ name: '.safetensors', value: 'safetensors' }
|
||||||
|
])
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Update base models
|
||||||
|
const baseModelSelect = wrapper.findAllComponents({
|
||||||
|
name: 'MultiSelect'
|
||||||
|
})[1]
|
||||||
|
await baseModelSelect.vm.$emit('update:modelValue', [
|
||||||
|
{ name: 'SD XL', value: 'sdxl' }
|
||||||
|
])
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Update sort
|
||||||
|
const sortSelect = wrapper.findComponent({ name: 'SingleSelect' })
|
||||||
|
await sortSelect.vm.$emit('update:modelValue', 'popular')
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('filterChange')
|
||||||
|
expect(emitted).toHaveLength(3)
|
||||||
|
|
||||||
|
// Check final state
|
||||||
|
const finalState: FilterState = emitted![2][0] as FilterState
|
||||||
|
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
|
||||||
|
expect(finalState.baseModels).toEqual(['sdxl'])
|
||||||
|
expect(finalState.sortBy).toBe('popular')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ensures FilterState interface compliance', async () => {
|
||||||
|
const wrapper = mount(AssetFilterBar)
|
||||||
|
|
||||||
|
const fileFormatSelect = wrapper.findAllComponents({
|
||||||
|
name: 'MultiSelect'
|
||||||
|
})[0]
|
||||||
|
await fileFormatSelect.vm.$emit('update:modelValue', [
|
||||||
|
{ name: '.ckpt', value: 'ckpt' }
|
||||||
|
])
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('filterChange')
|
||||||
|
const filterState = emitted![0][0] as FilterState
|
||||||
|
|
||||||
|
// Type and structure assertions
|
||||||
|
expect(Array.isArray(filterState.fileFormats)).toBe(true)
|
||||||
|
expect(Array.isArray(filterState.baseModels)).toBe(true)
|
||||||
|
expect(typeof filterState.sortBy).toBe('string')
|
||||||
|
|
||||||
|
// Value type assertions
|
||||||
|
expect(filterState.fileFormats.every((f) => typeof f === 'string')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(filterState.baseModels.every((m) => typeof m === 'string')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
323
tests-ui/platform/assets/composables/useAssetBrowser.test.ts
Normal file
323
tests-ui/platform/assets/composables/useAssetBrowser.test.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
describe('useAssetBrowser', () => {
|
||||||
|
// Test fixtures - minimal data focused on functionality being tested
|
||||||
|
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'test-asset.safetensors',
|
||||||
|
asset_hash: 'blake3:abc123',
|
||||||
|
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',
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Asset Transformation', () => {
|
||||||
|
it('transforms API asset to include display properties', () => {
|
||||||
|
const apiAsset = createApiAsset({
|
||||||
|
size: 2147483648, // 2GB
|
||||||
|
user_metadata: { description: 'Test model' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
|
||||||
|
const result = transformAssetForDisplay(apiAsset)
|
||||||
|
|
||||||
|
// Preserves API properties
|
||||||
|
expect(result.id).toBe(apiAsset.id)
|
||||||
|
expect(result.name).toBe(apiAsset.name)
|
||||||
|
|
||||||
|
// Adds display properties
|
||||||
|
expect(result.description).toBe('Test model')
|
||||||
|
expect(result.formattedSize).toBe('2 GB')
|
||||||
|
expect(result.badges).toContainEqual({
|
||||||
|
label: 'checkpoints',
|
||||||
|
type: 'type'
|
||||||
|
})
|
||||||
|
expect(result.badges).toContainEqual({ label: '2 GB', type: 'size' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates fallback description from tags when metadata missing', () => {
|
||||||
|
const apiAsset = createApiAsset({
|
||||||
|
tags: ['models', 'loras'],
|
||||||
|
user_metadata: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
|
||||||
|
const result = transformAssetForDisplay(apiAsset)
|
||||||
|
|
||||||
|
expect(result.description).toBe('loras model')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats various file sizes correctly', () => {
|
||||||
|
const { transformAssetForDisplay } = useAssetBrowser([])
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{ size: 512, expected: '512 B' },
|
||||||
|
{ size: 1536, expected: '1.5 KB' },
|
||||||
|
{ size: 2097152, expected: '2 MB' },
|
||||||
|
{ size: 3221225472, expected: '3 GB' }
|
||||||
|
]
|
||||||
|
|
||||||
|
testCases.forEach(({ size, expected }) => {
|
||||||
|
const asset = createApiAsset({ size })
|
||||||
|
const result = transformAssetForDisplay(asset)
|
||||||
|
expect(result.formattedSize).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Tag-Based Filtering', () => {
|
||||||
|
it('filters assets by category tag', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
||||||
|
createApiAsset({ id: '2', tags: ['models', 'loras'] }),
|
||||||
|
createApiAsset({ id: '3', tags: ['models', 'checkpoints'] })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { selectedCategory, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
selectedCategory.value = 'checkpoints'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(2)
|
||||||
|
expect(
|
||||||
|
filteredAssets.value.every((asset) =>
|
||||||
|
asset.tags.includes('checkpoints')
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns all assets when category is "all"', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
||||||
|
createApiAsset({ id: '2', tags: ['models', 'loras'] })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { selectedCategory, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
selectedCategory.value = 'all'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Search Functionality', () => {
|
||||||
|
it('searches across asset name', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({ name: 'realistic_vision.safetensors' }),
|
||||||
|
createApiAsset({ name: 'anime_style.ckpt' }),
|
||||||
|
createApiAsset({ name: 'photorealistic_v2.safetensors' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
searchQuery.value = 'realistic'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(2)
|
||||||
|
expect(
|
||||||
|
filteredAssets.value.every((asset) =>
|
||||||
|
asset.name.toLowerCase().includes('realistic')
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches in user metadata description', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({
|
||||||
|
name: 'model1.safetensors',
|
||||||
|
user_metadata: { description: 'fantasy artwork model' }
|
||||||
|
}),
|
||||||
|
createApiAsset({
|
||||||
|
name: 'model2.safetensors',
|
||||||
|
user_metadata: { description: 'portrait photography' }
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
searchQuery.value = 'fantasy'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(1)
|
||||||
|
expect(filteredAssets.value[0].name).toBe('model1.safetensors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty search results', async () => {
|
||||||
|
const assets = [createApiAsset({ name: 'test.safetensors' })]
|
||||||
|
|
||||||
|
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
searchQuery.value = 'nonexistent'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Combined Search and Filtering', () => {
|
||||||
|
it('applies both search and category filter', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({
|
||||||
|
name: 'realistic_checkpoint.safetensors',
|
||||||
|
tags: ['models', 'checkpoints']
|
||||||
|
}),
|
||||||
|
createApiAsset({
|
||||||
|
name: 'realistic_lora.safetensors',
|
||||||
|
tags: ['models', 'loras']
|
||||||
|
}),
|
||||||
|
createApiAsset({
|
||||||
|
name: 'anime_checkpoint.safetensors',
|
||||||
|
tags: ['models', 'checkpoints']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const { searchQuery, selectedCategory, filteredAssets } =
|
||||||
|
useAssetBrowser(assets)
|
||||||
|
|
||||||
|
searchQuery.value = 'realistic'
|
||||||
|
selectedCategory.value = 'checkpoints'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(filteredAssets.value).toHaveLength(1)
|
||||||
|
expect(filteredAssets.value[0].name).toBe(
|
||||||
|
'realistic_checkpoint.safetensors'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sorting', () => {
|
||||||
|
it('sorts assets by name', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({ name: 'zebra.safetensors' }),
|
||||||
|
createApiAsset({ name: 'alpha.safetensors' }),
|
||||||
|
createApiAsset({ name: 'beta.safetensors' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { sortBy, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
sortBy.value = 'name'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const names = filteredAssets.value.map((asset) => asset.name)
|
||||||
|
expect(names).toEqual([
|
||||||
|
'alpha.safetensors',
|
||||||
|
'beta.safetensors',
|
||||||
|
'zebra.safetensors'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts assets by creation date', async () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({ created_at: '2024-03-01T00:00:00Z' }),
|
||||||
|
createApiAsset({ created_at: '2024-01-01T00:00:00Z' }),
|
||||||
|
createApiAsset({ created_at: '2024-02-01T00:00:00Z' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { sortBy, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
sortBy.value = 'date'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const dates = filteredAssets.value.map((asset) => asset.created_at)
|
||||||
|
expect(dates).toEqual([
|
||||||
|
'2024-03-01T00:00:00Z',
|
||||||
|
'2024-02-01T00:00:00Z',
|
||||||
|
'2024-01-01T00:00:00Z'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Asset Selection', () => {
|
||||||
|
it('returns selected asset UUID for efficient handling', () => {
|
||||||
|
const asset = createApiAsset({
|
||||||
|
id: 'test-uuid-123',
|
||||||
|
name: 'selected_model.safetensors'
|
||||||
|
})
|
||||||
|
const { selectAsset, transformAssetForDisplay } = useAssetBrowser([asset])
|
||||||
|
|
||||||
|
const displayAsset = transformAssetForDisplay(asset)
|
||||||
|
const result = selectAsset(displayAsset)
|
||||||
|
|
||||||
|
expect(result).toBe('test-uuid-123')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dynamic Category Extraction', () => {
|
||||||
|
it('extracts categories from asset tags', () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({ tags: ['models', 'checkpoints'] }),
|
||||||
|
createApiAsset({ tags: ['models', 'loras'] }),
|
||||||
|
createApiAsset({ tags: ['models', 'checkpoints'] }) // duplicate
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableCategories } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
expect(availableCategories.value).toEqual([
|
||||||
|
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
|
||||||
|
{
|
||||||
|
id: 'checkpoints',
|
||||||
|
label: 'Checkpoints',
|
||||||
|
icon: 'icon-[lucide--package]'
|
||||||
|
},
|
||||||
|
{ id: 'loras', label: 'Loras', icon: 'icon-[lucide--package]' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles assets with no category tag', () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({ tags: ['models'] }), // No second tag
|
||||||
|
createApiAsset({ tags: ['models', 'vae'] })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableCategories } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
expect(availableCategories.value).toEqual([
|
||||||
|
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
|
||||||
|
{ id: 'vae', label: 'Vae', icon: 'icon-[lucide--package]' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores non-models root tags', () => {
|
||||||
|
const assets = [
|
||||||
|
createApiAsset({ tags: ['input', 'images'] }),
|
||||||
|
createApiAsset({ tags: ['models', 'checkpoints'] })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableCategories } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
expect(availableCategories.value).toEqual([
|
||||||
|
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
|
||||||
|
{
|
||||||
|
id: 'checkpoints',
|
||||||
|
label: 'Checkpoints',
|
||||||
|
icon: 'icon-[lucide--package]'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes content title from selected category', () => {
|
||||||
|
const assets = [createApiAsset({ tags: ['models', 'checkpoints'] })]
|
||||||
|
const { selectedCategory, contentTitle } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
// Default
|
||||||
|
expect(contentTitle.value).toBe('All Models')
|
||||||
|
|
||||||
|
// Set specific category
|
||||||
|
selectedCategory.value = 'checkpoints'
|
||||||
|
expect(contentTitle.value).toBe('Checkpoints')
|
||||||
|
|
||||||
|
// Unknown category
|
||||||
|
selectedCategory.value = 'unknown'
|
||||||
|
expect(contentTitle.value).toBe('Assets')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
|
// Mock the dialog store
|
||||||
|
vi.mock('@/stores/dialogStore')
|
||||||
|
|
||||||
|
// Test factory functions
|
||||||
|
interface AssetBrowserProps {
|
||||||
|
nodeType: string
|
||||||
|
inputName: string
|
||||||
|
onAssetSelected?: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAssetBrowserProps(
|
||||||
|
overrides: Partial<AssetBrowserProps> = {}
|
||||||
|
): AssetBrowserProps {
|
||||||
|
return {
|
||||||
|
nodeType: 'CheckpointLoaderSimple',
|
||||||
|
inputName: 'ckpt_name',
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAssetBrowserDialog', () => {
|
||||||
|
describe('Asset Selection Flow', () => {
|
||||||
|
it('auto-closes dialog when asset is selected', () => {
|
||||||
|
// Create fresh mocks for this test
|
||||||
|
const mockShowDialog = vi.fn()
|
||||||
|
const mockCloseDialog = vi.fn()
|
||||||
|
|
||||||
|
vi.mocked(useDialogStore).mockReturnValue({
|
||||||
|
showDialog: mockShowDialog,
|
||||||
|
closeDialog: mockCloseDialog
|
||||||
|
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||||
|
typeof useDialogStore
|
||||||
|
>)
|
||||||
|
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
const onAssetSelected = vi.fn()
|
||||||
|
const props = createAssetBrowserProps({ onAssetSelected })
|
||||||
|
|
||||||
|
assetBrowserDialog.show(props)
|
||||||
|
|
||||||
|
// Get the onSelect handler that was passed to the dialog
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
const onSelectHandler = dialogCall.props.onSelect
|
||||||
|
|
||||||
|
// Simulate asset selection
|
||||||
|
onSelectHandler('selected-asset-path')
|
||||||
|
|
||||||
|
// Should call the original callback and close dialog
|
||||||
|
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
|
||||||
|
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||||
|
key: 'global-asset-browser'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes dialog when close handler is called', () => {
|
||||||
|
// Create fresh mocks for this test
|
||||||
|
const mockShowDialog = vi.fn()
|
||||||
|
const mockCloseDialog = vi.fn()
|
||||||
|
|
||||||
|
vi.mocked(useDialogStore).mockReturnValue({
|
||||||
|
showDialog: mockShowDialog,
|
||||||
|
closeDialog: mockCloseDialog
|
||||||
|
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||||
|
typeof useDialogStore
|
||||||
|
>)
|
||||||
|
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
const props = createAssetBrowserProps()
|
||||||
|
|
||||||
|
assetBrowserDialog.show(props)
|
||||||
|
|
||||||
|
// Get the onClose handler that was passed to the dialog
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
const onCloseHandler = dialogCall.props.onClose
|
||||||
|
|
||||||
|
// Simulate dialog close
|
||||||
|
onCloseHandler()
|
||||||
|
|
||||||
|
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||||
|
key: 'global-asset-browser'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
// Test factory functions
|
||||||
|
function createTestAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||||
|
return {
|
||||||
|
id: 'test-uuid',
|
||||||
|
name: 'test-model.safetensors',
|
||||||
|
asset_hash: 'blake3:test123',
|
||||||
|
size: 123456,
|
||||||
|
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',
|
||||||
|
user_metadata: {
|
||||||
|
base_model: 'sd15'
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAssetFilterOptions', () => {
|
||||||
|
describe('File Format Extraction', () => {
|
||||||
|
it('extracts file formats from asset names', () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset({ name: 'model1.safetensors' }),
|
||||||
|
createTestAsset({ name: 'model2.ckpt' }),
|
||||||
|
createTestAsset({ name: 'model3.pt' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||||
|
|
||||||
|
expect(availableFileFormats.value).toEqual([
|
||||||
|
{ name: '.ckpt', value: 'ckpt' },
|
||||||
|
{ name: '.pt', value: 'pt' },
|
||||||
|
{ name: '.safetensors', value: 'safetensors' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles duplicate file formats', () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset({ name: 'model1.safetensors' }),
|
||||||
|
createTestAsset({ name: 'model2.safetensors' }),
|
||||||
|
createTestAsset({ name: 'model3.ckpt' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||||
|
|
||||||
|
expect(availableFileFormats.value).toEqual([
|
||||||
|
{ name: '.ckpt', value: 'ckpt' },
|
||||||
|
{ name: '.safetensors', value: 'safetensors' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles assets with no file extension', () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset({ name: 'model_no_extension' }),
|
||||||
|
createTestAsset({ name: 'model.safetensors' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||||
|
|
||||||
|
expect(availableFileFormats.value).toEqual([
|
||||||
|
{ name: '.safetensors', value: 'safetensors' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty asset list', () => {
|
||||||
|
const { availableFileFormats } = useAssetFilterOptions([])
|
||||||
|
|
||||||
|
expect(availableFileFormats.value).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Base Model Extraction', () => {
|
||||||
|
it('extracts base models from user metadata', () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||||
|
createTestAsset({ user_metadata: { base_model: 'sdxl' } }),
|
||||||
|
createTestAsset({ user_metadata: { base_model: 'sd35' } })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||||
|
|
||||||
|
expect(availableBaseModels.value).toEqual([
|
||||||
|
{ name: 'sd15', value: 'sd15' },
|
||||||
|
{ name: 'sd35', value: 'sd35' },
|
||||||
|
{ name: 'sdxl', value: 'sdxl' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles duplicate base models', () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||||
|
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||||
|
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||||
|
|
||||||
|
expect(availableBaseModels.value).toEqual([
|
||||||
|
{ name: 'sd15', value: 'sd15' },
|
||||||
|
{ name: 'sdxl', value: 'sdxl' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles assets with missing user_metadata', () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset({ user_metadata: undefined }),
|
||||||
|
createTestAsset({ user_metadata: { base_model: 'sd15' } })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||||
|
|
||||||
|
expect(availableBaseModels.value).toEqual([
|
||||||
|
{ name: 'sd15', value: 'sd15' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles assets with missing base_model field', () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset({ user_metadata: { description: 'A test model' } }),
|
||||||
|
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
|
||||||
|
]
|
||||||
|
|
||||||
|
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||||
|
|
||||||
|
expect(availableBaseModels.value).toEqual([
|
||||||
|
{ name: 'sdxl', value: 'sdxl' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty asset list', () => {
|
||||||
|
const { availableBaseModels } = useAssetFilterOptions([])
|
||||||
|
|
||||||
|
expect(availableBaseModels.value).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Reactivity', () => {
|
||||||
|
it('returns computed properties that can be reactive', () => {
|
||||||
|
const assets = [createTestAsset({ name: 'model.safetensors' })]
|
||||||
|
|
||||||
|
const { availableFileFormats, availableBaseModels } =
|
||||||
|
useAssetFilterOptions(assets)
|
||||||
|
|
||||||
|
// These should be computed refs
|
||||||
|
expect(availableFileFormats.value).toBeDefined()
|
||||||
|
expect(availableBaseModels.value).toBeDefined()
|
||||||
|
expect(typeof availableFileFormats.value).toBe('object')
|
||||||
|
expect(typeof availableBaseModels.value).toBe('object')
|
||||||
|
expect(Array.isArray(availableFileFormats.value)).toBe(true)
|
||||||
|
expect(Array.isArray(availableBaseModels.value)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import {
|
||||||
|
getAssetBaseModel,
|
||||||
|
getAssetDescription
|
||||||
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
|
||||||
|
describe('assetMetadataUtils', () => {
|
||||||
|
const mockAsset: AssetItem = {
|
||||||
|
id: 'test-id',
|
||||||
|
name: 'test-model',
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
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('should return null when no metadata', () => {
|
||||||
|
expect(getAssetDescription(mockAsset)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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('should return null when no metadata', () => {
|
||||||
|
expect(getAssetBaseModel(mockAsset)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
|
|
||||||
@@ -17,26 +18,39 @@ vi.mock('@/stores/modelToNodeStore', () => ({
|
|||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Helper to create API-compliant test assets
|
||||||
|
function createTestAsset(overrides: Partial<AssetItem> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'test-uuid',
|
||||||
|
name: 'test-model.safetensors',
|
||||||
|
asset_hash: 'blake3:test123',
|
||||||
|
size: 123456,
|
||||||
|
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',
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test data constants
|
// Test data constants
|
||||||
const MOCK_ASSETS = {
|
const MOCK_ASSETS = {
|
||||||
checkpoints: {
|
checkpoints: createTestAsset({
|
||||||
id: 'uuid-1',
|
id: 'uuid-1',
|
||||||
name: 'model1.safetensors',
|
name: 'model1.safetensors',
|
||||||
tags: ['models', 'checkpoints'],
|
tags: ['models', 'checkpoints']
|
||||||
size: 123456
|
}),
|
||||||
},
|
loras: createTestAsset({
|
||||||
loras: {
|
|
||||||
id: 'uuid-2',
|
id: 'uuid-2',
|
||||||
name: 'model2.safetensors',
|
name: 'model2.safetensors',
|
||||||
tags: ['models', 'loras'],
|
tags: ['models', 'loras']
|
||||||
size: 654321
|
}),
|
||||||
},
|
vae: createTestAsset({
|
||||||
vae: {
|
|
||||||
id: 'uuid-3',
|
id: 'uuid-3',
|
||||||
name: 'vae1.safetensors',
|
name: 'vae1.safetensors',
|
||||||
tags: ['models', 'vae'],
|
tags: ['models', 'vae']
|
||||||
size: 789012
|
})
|
||||||
}
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
@@ -66,24 +80,21 @@ describe('assetService', () => {
|
|||||||
describe('getAssetModelFolders', () => {
|
describe('getAssetModelFolders', () => {
|
||||||
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
|
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
|
||||||
const assets = [
|
const assets = [
|
||||||
{
|
createTestAsset({
|
||||||
id: 'uuid-1',
|
id: 'uuid-1',
|
||||||
name: 'checkpoint1.safetensors',
|
name: 'checkpoint1.safetensors',
|
||||||
tags: ['models', 'checkpoints'],
|
tags: ['models', 'checkpoints']
|
||||||
size: 123456
|
}),
|
||||||
},
|
createTestAsset({
|
||||||
{
|
|
||||||
id: 'uuid-2',
|
id: 'uuid-2',
|
||||||
name: 'config.yaml',
|
name: 'config.yaml',
|
||||||
tags: ['models', 'configs'], // Blacklisted
|
tags: ['models', 'configs'] // Blacklisted
|
||||||
size: 654321
|
}),
|
||||||
},
|
createTestAsset({
|
||||||
{
|
|
||||||
id: 'uuid-3',
|
id: 'uuid-3',
|
||||||
name: 'vae1.safetensors',
|
name: 'vae1.safetensors',
|
||||||
tags: ['models', 'vae'],
|
tags: ['models', 'vae']
|
||||||
size: 789012
|
})
|
||||||
}
|
|
||||||
]
|
]
|
||||||
mockApiResponse(assets)
|
mockApiResponse(assets)
|
||||||
|
|
||||||
@@ -123,12 +134,11 @@ describe('assetService', () => {
|
|||||||
const assets = [
|
const assets = [
|
||||||
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
|
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
|
||||||
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
|
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
|
||||||
{
|
createTestAsset({
|
||||||
id: 'uuid-4',
|
id: 'uuid-4',
|
||||||
name: 'missing-model.safetensors',
|
name: 'missing-model.safetensors',
|
||||||
tags: ['models', 'checkpoints', 'missing'], // Has missing tag
|
tags: ['models', 'checkpoints', 'missing'] // Has missing tag
|
||||||
size: 654321
|
})
|
||||||
}
|
|
||||||
]
|
]
|
||||||
mockApiResponse(assets)
|
mockApiResponse(assets)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user