mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
[feat] add AssetBrowserModal
And all related sub components
This commit is contained in:
@@ -3,7 +3,7 @@ import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
|||||||
import IconsResolver from 'unplugin-icons/resolver'
|
import IconsResolver from 'unplugin-icons/resolver'
|
||||||
import Icons from 'unplugin-icons/vite'
|
import Icons from 'unplugin-icons/vite'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
import type { InlineConfig } from 'vite'
|
import type { InlineConfig, Plugin } from 'vite'
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||||
@@ -18,8 +18,14 @@ const config: StorybookConfig = {
|
|||||||
|
|
||||||
// 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 as Plugin[]).filter((plugin: Plugin) => {
|
||||||
if (plugin && plugin.name && plugin.name.includes('import-map')) {
|
if (
|
||||||
|
plugin &&
|
||||||
|
typeof plugin === 'object' &&
|
||||||
|
'name' in plugin &&
|
||||||
|
typeof plugin.name === 'string' &&
|
||||||
|
plugin.name.includes('import-map')
|
||||||
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -10,10 +10,9 @@ import ToastService from 'primevue/toastservice'
|
|||||||
import Tooltip from 'primevue/tooltip'
|
import Tooltip from 'primevue/tooltip'
|
||||||
|
|
||||||
import '../src/assets/css/style.css'
|
import '../src/assets/css/style.css'
|
||||||
|
import GlobalDialog from '../src/components/dialog/GlobalDialog.vue'
|
||||||
import { i18n } from '../src/i18n'
|
import { i18n } from '../src/i18n'
|
||||||
import '../src/lib/litegraph/public/css/litegraph.css'
|
import '../src/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,12 +24,14 @@ 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
|
// Register global components for dialogs
|
||||||
useColorPaletteStore(pinia)
|
app.component('GlobalDialog', GlobalDialog)
|
||||||
useWidgetStore(pinia)
|
|
||||||
|
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
@@ -50,8 +51,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 +64,19 @@ export const withTheme = (Story: any, context: any) => {
|
|||||||
document.body.classList.remove('dark-theme')
|
document.body.classList.remove('dark-theme')
|
||||||
}
|
}
|
||||||
|
|
||||||
return Story()
|
// Return story with GlobalDialog included
|
||||||
|
return {
|
||||||
|
components: { GlobalDialog },
|
||||||
|
setup() {
|
||||||
|
return { storyResult: Story(context.args, context) }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<component :is="storyResult" />
|
||||||
|
<GlobalDialog />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
|
|||||||
@@ -1862,5 +1862,14 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 gap-1 flex-wrap justify-end">
|
||||||
|
<span
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.label"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'px-2 py-1 rounded text-[10px] font-medium uppercase tracking-wider text-white',
|
||||||
|
getBadgeColor(badge.type)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ badge.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
interface Badge {
|
||||||
|
label: string
|
||||||
|
type: 'type' | 'base' | 'size'
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
badges: Badge[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function getBadgeColor(type: Badge['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>
|
||||||
175
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
175
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import { createMockAssets, mockAssets } from '../fixtures/ui-mock-assets'
|
||||||
|
import AssetBrowserModal from './AssetBrowserModal.vue'
|
||||||
|
|
||||||
|
// 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: undefined
|
||||||
|
},
|
||||||
|
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: undefined
|
||||||
|
},
|
||||||
|
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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/platform/assets/components/AssetBrowserModal.vue
Normal file
109
src/platform/assets/components/AssetBrowserModal.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<BaseModalLayout
|
||||||
|
data-component-id="AssetBrowserModal"
|
||||||
|
class="h-full w-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="Search assets..."
|
||||||
|
class="max-w-[384px]"
|
||||||
|
/>
|
||||||
|
</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 type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
import { useAssetBrowser } from '../composables/useAssetBrowser'
|
||||||
|
import { mockAssets } from '../fixtures/ui-mock-assets'
|
||||||
|
import AssetGrid from './AssetGrid.vue'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps<{
|
||||||
|
nodeType?: string
|
||||||
|
inputName?: string
|
||||||
|
onSelect?: (assetPath: string) => void
|
||||||
|
onClose?: () => void
|
||||||
|
showLeftPanel?: boolean
|
||||||
|
assets?: AssetItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'asset-select': [asset: AssetDisplayItem]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Use provided assets or fallback to mock data
|
||||||
|
const assetsToUse = props.assets !== undefined ? props.assets : mockAssets
|
||||||
|
|
||||||
|
// Use AssetBrowser composable for all business logic
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
selectedCategory,
|
||||||
|
availableCategories,
|
||||||
|
contentTitle,
|
||||||
|
filteredAssets,
|
||||||
|
selectAsset
|
||||||
|
} = useAssetBrowser(assetsToUse)
|
||||||
|
|
||||||
|
// Compute whether to show left panel
|
||||||
|
const shouldShowLeftPanel = computed(() => {
|
||||||
|
// If explicitly set to false, don't show
|
||||||
|
if (props.showLeftPanel === false) return false
|
||||||
|
|
||||||
|
// If explicitly set to true, always show
|
||||||
|
if (props.showLeftPanel === true) return true
|
||||||
|
|
||||||
|
// Auto-hide if only one unique asset category (excluding "All Models")
|
||||||
|
return availableCategories.value.length >= 3
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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>
|
||||||
156
src/platform/assets/components/AssetCard.stories.ts
Normal file
156
src/platform/assets/components/AssetCard.stories.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
|
||||||
|
|
||||||
|
import AssetCard from './AssetCard.vue'
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
// Default story with all data provided
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
asset: createAssetData()
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Default AssetCard with complete data including badges and all stats.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Story with all edge cases in a grid layout
|
||||||
|
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"
|
||||||
|
@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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/platform/assets/components/AssetCard.vue
Normal file
91
src/platform/assets/components/AssetCard.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-component-id="AssetCard"
|
||||||
|
:data-asset-id="asset.id"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-label="`Select asset ${asset.name}`"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'rounded-xl overflow-hidden cursor-pointer transition-all duration-200 min-w-60 max-w-64',
|
||||||
|
'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-[0_8px_24px_rgba(0,0,0,0.1)] hover:border-gray-400',
|
||||||
|
'dark-theme:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)] dark-theme:hover:border-charcoal-700',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="$emit('select', asset)"
|
||||||
|
@keydown.enter="$emit('select', asset)"
|
||||||
|
@keydown.space.prevent="$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"
|
||||||
|
></div>
|
||||||
|
<AssetBadgeGroup :badges="asset.badges" />
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<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(
|
||||||
|
'mb-3 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
|
||||||
|
: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 class="pi pi-star text-xs"></i>
|
||||||
|
{{ asset.stats.stars }}
|
||||||
|
</span>
|
||||||
|
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
|
||||||
|
<i class="pi pi-download text-xs"></i>
|
||||||
|
{{ asset.stats.downloadCount }}
|
||||||
|
</span>
|
||||||
|
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
|
||||||
|
<i class="pi pi-clock text-xs"></i>
|
||||||
|
{{ asset.stats.formattedDate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import AssetBadgeGroup from './AssetBadgeGroup.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
asset: AssetDisplayItem
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
select: [asset: AssetDisplayItem]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
107
src/platform/assets/components/AssetFilterBar.vue
Normal file
107
src/platform/assets/components/AssetFilterBar.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="containerClasses" data-component-id="asset-filter-bar">
|
||||||
|
<!-- Left Side Filters -->
|
||||||
|
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
||||||
|
<!-- File Format MultiSelect -->
|
||||||
|
<MultiSelect
|
||||||
|
v-model="fileFormats"
|
||||||
|
label="File formats"
|
||||||
|
:options="fileFormatOptions"
|
||||||
|
:class="selectClasses"
|
||||||
|
data-component-id="asset-filter-file-formats"
|
||||||
|
@update:model-value="handleFilterChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Base Model MultiSelect -->
|
||||||
|
<MultiSelect
|
||||||
|
v-model="baseModels"
|
||||||
|
label="Base models"
|
||||||
|
:options="baseModelOptions"
|
||||||
|
:class="selectClasses"
|
||||||
|
data-component-id="asset-filter-base-models"
|
||||||
|
@update:model-value="handleFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side Sort -->
|
||||||
|
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
|
||||||
|
<!-- Sort SingleSelect -->
|
||||||
|
<SingleSelect
|
||||||
|
v-model="sortBy"
|
||||||
|
label="Sort by"
|
||||||
|
:options="sortOptions"
|
||||||
|
:class="selectClasses"
|
||||||
|
data-component-id="asset-filter-sort"
|
||||||
|
@update:model-value="handleFilterChange"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i class="icon-[lucide--arrow-up-down] 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"
|
||||||
|
class="grid grid-cols-2 md:grid-cols-4 gap-6 p-8"
|
||||||
|
role="grid"
|
||||||
|
aria-label="Asset collection"
|
||||||
|
:aria-rowcount="-1"
|
||||||
|
:aria-colcount="-1"
|
||||||
|
:aria-setsize="assets.length"
|
||||||
|
>
|
||||||
|
<AssetCard
|
||||||
|
v-for="(asset, index) in assets"
|
||||||
|
:key="asset.id"
|
||||||
|
:asset="asset"
|
||||||
|
role="gridcell"
|
||||||
|
:aria-posinset="index + 1"
|
||||||
|
@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 class="pi pi-search text-4xl mb-4"></i>
|
||||||
|
<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
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'pi pi-spinner pi-spin text-2xl',
|
||||||
|
'text-stone-300',
|
||||||
|
'dark-theme:text-stone-200'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import AssetCard from './AssetCard.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
assets: AssetDisplayItem[]
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
assetSelect: [asset: AssetDisplayItem]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
203
src/platform/assets/composables/useAssetBrowser.ts
Normal file
203
src/platform/assets/composables/useAssetBrowser.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
// Display properties for transformed assets
|
||||||
|
export interface AssetDisplayItem extends AssetItem {
|
||||||
|
description: string
|
||||||
|
formattedSize: string
|
||||||
|
badges: Array<{
|
||||||
|
label: string
|
||||||
|
type: 'type' | 'base' | 'size'
|
||||||
|
}>
|
||||||
|
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 description =
|
||||||
|
asset.user_metadata?.description ||
|
||||||
|
`${asset.tags.find((tag) => tag !== 'models') || 'Unknown'} model`
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
const formattedSize = formatFileSize(asset.size)
|
||||||
|
|
||||||
|
// Create badges from tags and metadata
|
||||||
|
const badges = []
|
||||||
|
|
||||||
|
// Type badge from non-root tag
|
||||||
|
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||||
|
if (typeTag) {
|
||||||
|
badges.push({ label: typeTag, type: 'type' as const })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base model badge from metadata
|
||||||
|
if (asset.user_metadata?.base_model) {
|
||||||
|
badges.push({
|
||||||
|
label: asset.user_metadata.base_model,
|
||||||
|
type: 'base' as const
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size badge
|
||||||
|
badges.push({ label: formattedSize, type: 'size' as const })
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format file sizes
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let size = bytes
|
||||||
|
let unitIndex = 0
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: 'All Models', icon: 'i-lucide:folder' },
|
||||||
|
...Array.from(categorySet)
|
||||||
|
.sort()
|
||||||
|
.map((category) => ({
|
||||||
|
id: category,
|
||||||
|
label: category.charAt(0).toUpperCase() + category.slice(1),
|
||||||
|
icon: 'i-lucide:package'
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute content title from selected category
|
||||||
|
const contentTitle = computed(() => {
|
||||||
|
if (selectedCategory.value === 'all') {
|
||||||
|
return 'All Models'
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = availableCategories.value.find(
|
||||||
|
(cat) => cat.id === selectedCategory.value
|
||||||
|
)
|
||||||
|
return category?.label || 'Assets'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed filtered and transformed assets
|
||||||
|
const filteredAssets = computed(() => {
|
||||||
|
let filtered = [...assets]
|
||||||
|
|
||||||
|
// Filter by category (tag-based)
|
||||||
|
if (selectedCategory.value !== 'all') {
|
||||||
|
filtered = filtered.filter((asset) =>
|
||||||
|
asset.tags.includes(selectedCategory.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(asset) =>
|
||||||
|
asset.name.toLowerCase().includes(query) ||
|
||||||
|
asset.user_metadata?.description?.toLowerCase().includes(query) ||
|
||||||
|
asset.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 setSearchQuery(query: string) {
|
||||||
|
searchQuery.value = query
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCategory(category: string) {
|
||||||
|
selectedCategory.value = category
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSortBy(sort: string) {
|
||||||
|
sortBy.value = sort
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
setSearchQuery,
|
||||||
|
setCategory,
|
||||||
|
setSortBy,
|
||||||
|
selectAsset,
|
||||||
|
transformAssetForDisplay
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/platform/assets/composables/useAssetBrowserDialog.stories.ts
Normal file
200
src/platform/assets/composables/useAssetBrowserDialog.stories.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import AssetBrowserModal from '../components/AssetBrowserModal.vue'
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
||||||
|
: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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/platform/assets/composables/useAssetBrowserDialog.ts
Normal file
68
src/platform/assets/composables/useAssetBrowserDialog.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
|
import AssetBrowserModal from '../components/AssetBrowserModal.vue'
|
||||||
|
|
||||||
|
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 h-[80vh] w-[80vw] max-h-[80vh] max-w-[80vw]'
|
||||||
|
},
|
||||||
|
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 }
|
||||||
|
}
|
||||||
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.any()).optional(),
|
||||||
|
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)
|
||||||
) ?? []
|
) ?? []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
322
tests-ui/platform/assets/components/AssetBrowserModal.test.ts
Normal file
322
tests-ui/platform/assets/components/AssetBrowserModal.test.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { VueWrapper, mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, 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', () => {
|
||||||
|
let wrapper: VueWrapper
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
wrapper = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
]
|
||||||
|
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')
|
||||||
|
]
|
||||||
|
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')
|
||||||
|
]
|
||||||
|
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')
|
||||||
|
]
|
||||||
|
createWrapper(assets)
|
||||||
|
|
||||||
|
// 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')]
|
||||||
|
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')]
|
||||||
|
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 when only one category exists', () => {
|
||||||
|
const singleCategoryAssets = [
|
||||||
|
createTestAsset('single1', 'Asset 1', 'checkpoints'),
|
||||||
|
createTestAsset('single2', 'Asset 2', 'checkpoints')
|
||||||
|
]
|
||||||
|
createWrapper(singleCategoryAssets)
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows left panel when multiple categories exist', async () => {
|
||||||
|
const multiCategoryAssets = [
|
||||||
|
createTestAsset('asset1', 'Checkpoint', 'checkpoints'),
|
||||||
|
createTestAsset('asset2', 'LoRA', 'loras')
|
||||||
|
]
|
||||||
|
createWrapper(multiCategoryAssets)
|
||||||
|
|
||||||
|
// Wait for Vue reactivity to compute shouldShowLeftPanel
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects explicit showLeftPanel prop override', () => {
|
||||||
|
const singleCategoryAssets = [
|
||||||
|
createTestAsset('single1', 'Asset 1', 'checkpoints')
|
||||||
|
]
|
||||||
|
|
||||||
|
// Force show even with single category
|
||||||
|
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')
|
||||||
|
]
|
||||||
|
createWrapper(multiCategoryAssets, { showLeftPanel: false })
|
||||||
|
expect(wrapper.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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
315
tests-ui/platform/assets/composables/useAssetBrowser.test.ts
Normal file
315
tests-ui/platform/assets/composables/useAssetBrowser.test.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
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.0 GB')
|
||||||
|
expect(result.badges).toContainEqual({
|
||||||
|
label: 'checkpoints',
|
||||||
|
type: 'type'
|
||||||
|
})
|
||||||
|
expect(result.badges).toContainEqual({ label: '2.0 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.0 B' },
|
||||||
|
{ size: 1536, expected: '1.5 KB' },
|
||||||
|
{ size: 2097152, expected: '2.0 MB' },
|
||||||
|
{ size: 3221225472, expected: '3.0 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 { setCategory, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
setCategory('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 { setCategory, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
setCategory('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 { setSearchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
setSearchQuery('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 { setSearchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
setSearchQuery('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 { setSearchQuery, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
setSearchQuery('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 { setSearchQuery, setCategory, filteredAssets } =
|
||||||
|
useAssetBrowser(assets)
|
||||||
|
|
||||||
|
setSearchQuery('realistic')
|
||||||
|
setCategory('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 { setSortBy, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
setSortBy('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 { setSortBy, filteredAssets } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
setSortBy('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: 'i-lucide:folder' },
|
||||||
|
{ id: 'checkpoints', label: 'Checkpoints', icon: 'i-lucide:package' },
|
||||||
|
{ id: 'loras', label: 'Loras', icon: 'i-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: 'i-lucide:folder' },
|
||||||
|
{ id: 'vae', label: 'Vae', icon: 'i-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: 'i-lucide:folder' },
|
||||||
|
{ id: 'checkpoints', label: 'Checkpoints', icon: 'i-lucide:package' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes content title from selected category', () => {
|
||||||
|
const assets = [createApiAsset({ tags: ['models', 'checkpoints'] })]
|
||||||
|
const { setCategory, contentTitle } = useAssetBrowser(assets)
|
||||||
|
|
||||||
|
// Default
|
||||||
|
expect(contentTitle.value).toBe('All Models')
|
||||||
|
|
||||||
|
// Set specific category
|
||||||
|
setCategory('checkpoints')
|
||||||
|
expect(contentTitle.value).toBe('Checkpoints')
|
||||||
|
|
||||||
|
// Unknown category
|
||||||
|
setCategory('unknown')
|
||||||
|
expect(contentTitle.value).toBe('Assets')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||||
|
|
||||||
|
// Mock the dialog store
|
||||||
|
let mockShowDialog: ReturnType<typeof vi.fn>
|
||||||
|
let mockCloseDialog: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
vi.mock('@/stores/dialogStore', () => ({
|
||||||
|
useDialogStore: vi.fn(() => ({
|
||||||
|
showDialog: mockShowDialog,
|
||||||
|
closeDialog: mockCloseDialog
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 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', () => {
|
||||||
|
let assetBrowserDialog: ReturnType<typeof useAssetBrowserDialog>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockShowDialog = vi.fn()
|
||||||
|
mockCloseDialog = vi.fn()
|
||||||
|
assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Asset Selection Flow', () => {
|
||||||
|
it('auto-closes dialog when asset is selected', () => {
|
||||||
|
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', () => {
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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