mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-01 19:20:10 +00:00
Asset Browser Modal Component (#5607)
* [ci] ignore playwright mcp directory * [feat] add AssetBrowserModal And all related sub components * [feat] reactive filter functions * [ci] clean up storybook config * [feat] add sematic AssetCard * [fix] i love lucide * [fix] AssetCard layout issues * [fix] add AssetBadge type * [fix] simplify useAssetBrowser * [fix] modal layout * [fix] simplify useAssetBrowserDialog * [fix] add tailwind back to storybook * [fix] better reponsive layout * [fix] missed i18n string * [fix] missing i18n translations * [fix] remove erroneous prevent on keyboard.space * [feat] add asset metadata validation utilities * [fix] remove erroneous test code * [fix] remove forced min and max width on AssetCard * [fix] import statement nits
This commit is contained in:
@@ -1862,5 +1862,17 @@
|
||||
"showGroups": "Show Frames/Groups",
|
||||
"renderBypassState": "Render Bypass State",
|
||||
"renderErrorState": "Render Error State"
|
||||
},
|
||||
"assetBrowser": {
|
||||
"assets": "Assets",
|
||||
"browseAssets": "Browse Assets",
|
||||
"noAssetsFound": "No assets found",
|
||||
"tryAdjustingFilters": "Try adjusting your search or filters",
|
||||
"loadingModels": "Loading {type}...",
|
||||
"connectionError": "Please check your connection and try again",
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"searchAssetsPlaceholder": "Search assets...",
|
||||
"allModels": "All Models",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
42
src/platform/assets/components/AssetBadgeGroup.vue
Normal file
42
src/platform/assets/components/AssetBadgeGroup.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
:class="
|
||||
cn(
|
||||
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
|
||||
getBadgeColor(badge.type)
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type AssetBadge = {
|
||||
label: string
|
||||
type: 'type' | 'base' | 'size'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
badges: AssetBadge[]
|
||||
}>()
|
||||
|
||||
function getBadgeColor(type: AssetBadge['type']): string {
|
||||
switch (type) {
|
||||
case 'type':
|
||||
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
|
||||
case 'base':
|
||||
return 'bg-success-100/90 dark-theme:bg-success-100/80'
|
||||
case 'size':
|
||||
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
|
||||
default:
|
||||
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
178
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
178
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import {
|
||||
createMockAssets,
|
||||
mockAssets
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
// Story arguments interface
|
||||
interface StoryArgs {
|
||||
nodeType: string
|
||||
inputName: string
|
||||
currentValue: string
|
||||
showLeftPanel?: boolean
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Platform/Assets/AssetBrowserModal',
|
||||
component: AssetBrowserModal,
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
argTypes: {
|
||||
nodeType: {
|
||||
control: 'select',
|
||||
options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'],
|
||||
description: 'ComfyUI node type for context'
|
||||
},
|
||||
inputName: {
|
||||
control: 'select',
|
||||
options: ['ckpt_name', 'vae_name', 'control_net_name'],
|
||||
description: 'Widget input name'
|
||||
},
|
||||
currentValue: {
|
||||
control: 'text',
|
||||
description: 'Current selected asset value'
|
||||
},
|
||||
showLeftPanel: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show the left panel with categories'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Modal Layout Stories
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
return {
|
||||
...args,
|
||||
onAssetSelect,
|
||||
onClose,
|
||||
assets: mockAssets
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story demonstrating single asset type (auto-hides left panel)
|
||||
export const SingleAssetType: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
// Create assets with only one type (checkpoints)
|
||||
const singleTypeAssets = createMockAssets(15).map((asset) => ({
|
||||
...asset,
|
||||
type: 'checkpoint'
|
||||
}))
|
||||
|
||||
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Modal with assets of only one type (checkpoint) - left panel auto-hidden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Story with left panel explicitly hidden
|
||||
export const NoLeftPanel: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
return { ...args, onAssetSelect, onClose, assets: mockAssets }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Modal with left panel explicitly disabled via showLeftPanel=false.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/platform/assets/components/AssetBrowserModal.vue
Normal file
95
src/platform/assets/components/AssetBrowserModal.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
data-component-id="AssetBrowserModal"
|
||||
class="size-full max-h-full max-w-full min-w-0"
|
||||
:content-title="contentTitle"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template v-if="shouldShowLeftPanel" #leftPanel>
|
||||
<LeftSidePanel
|
||||
v-model="selectedCategory"
|
||||
data-component-id="AssetBrowserModal-LeftSidePanel"
|
||||
:nav-items="availableCategories"
|
||||
>
|
||||
<template #header-icon>
|
||||
<i-lucide:folder class="size-4" />
|
||||
</template>
|
||||
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
|
||||
</LeftSidePanel>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
|
||||
class="max-w-96"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<AssetGrid
|
||||
:assets="filteredAssets"
|
||||
@asset-select="handleAssetSelectAndEmit"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeType?: string
|
||||
inputName?: string
|
||||
onSelect?: (assetPath: string) => void
|
||||
onClose?: () => void
|
||||
showLeftPanel?: boolean
|
||||
assets?: AssetItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// Use AssetBrowser composable for all business logic
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
selectAsset
|
||||
} = useAssetBrowser(props.assets)
|
||||
|
||||
// Dialog controls panel visibility via prop
|
||||
const shouldShowLeftPanel = computed(() => {
|
||||
return props.showLeftPanel ?? true
|
||||
})
|
||||
|
||||
// Handle close button - call both the prop callback and emit the event
|
||||
const handleClose = () => {
|
||||
props.onClose?.()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Handle asset selection and emit to parent
|
||||
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
|
||||
selectAsset(asset) // This logs the selection for dev mode
|
||||
emit('asset-select', asset) // Emit the full asset object
|
||||
|
||||
// Call prop callback if provided
|
||||
if (props.onSelect) {
|
||||
props.onSelect(asset.name) // Use asset name as the asset path
|
||||
}
|
||||
}
|
||||
</script>
|
||||
182
src/platform/assets/components/AssetCard.stories.ts
Normal file
182
src/platform/assets/components/AssetCard.stories.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
// Use the first mock asset as base and transform it to display format
|
||||
const baseAsset = mockAssets[0]
|
||||
const createAssetData = (
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem => ({
|
||||
...baseAsset,
|
||||
description:
|
||||
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
|
||||
formattedSize: '2.1 GB',
|
||||
badges: [
|
||||
{ label: 'checkpoints', type: 'type' },
|
||||
{ label: '2.1 GB', type: 'size' }
|
||||
],
|
||||
stats: {
|
||||
formattedDate: '3/15/25',
|
||||
downloadCount: '1.8k',
|
||||
stars: '4.2k'
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
|
||||
const meta: Meta<typeof AssetCard> = {
|
||||
title: 'Platform/Assets/AssetCard',
|
||||
component: AssetCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
asset: createAssetData(),
|
||||
interactive: true
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||
})
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Default AssetCard with complete data including badges and all stats.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NonInteractive: Story = {
|
||||
args: {
|
||||
asset: createAssetData(),
|
||||
interactive: false
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||
})
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'AssetCard in non-interactive mode - renders as div without button semantics.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EdgeCases: Story = {
|
||||
render: () => ({
|
||||
components: { AssetCard },
|
||||
setup() {
|
||||
const edgeCases = [
|
||||
// Default case for comparison
|
||||
createAssetData({
|
||||
name: 'Complete Data',
|
||||
description: 'Asset with all data present for comparison'
|
||||
}),
|
||||
// No badges
|
||||
createAssetData({
|
||||
id: 'no-badges',
|
||||
name: 'No Badges',
|
||||
description: 'Testing graceful handling when badges are not provided',
|
||||
badges: []
|
||||
}),
|
||||
// No stars
|
||||
createAssetData({
|
||||
id: 'no-stars',
|
||||
name: 'No Stars',
|
||||
description: 'Testing missing stars data gracefully',
|
||||
stats: {
|
||||
downloadCount: '1.8k',
|
||||
formattedDate: '3/15/25'
|
||||
}
|
||||
}),
|
||||
// No downloads
|
||||
createAssetData({
|
||||
id: 'no-downloads',
|
||||
name: 'No Downloads',
|
||||
description: 'Testing missing downloads data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
formattedDate: '3/15/25'
|
||||
}
|
||||
}),
|
||||
// No date
|
||||
createAssetData({
|
||||
id: 'no-date',
|
||||
name: 'No Date',
|
||||
description: 'Testing missing date data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
downloadCount: '1.8k'
|
||||
}
|
||||
}),
|
||||
// No stats at all
|
||||
createAssetData({
|
||||
id: 'no-stats',
|
||||
name: 'No Stats',
|
||||
description: 'Testing when all stats are missing',
|
||||
stats: {}
|
||||
}),
|
||||
// Long description
|
||||
createAssetData({
|
||||
id: 'long-desc',
|
||||
name: 'Long Description',
|
||||
description:
|
||||
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
|
||||
}),
|
||||
// Minimal data
|
||||
createAssetData({
|
||||
id: 'minimal',
|
||||
name: 'Minimal',
|
||||
description: 'Basic model',
|
||||
tags: ['models'],
|
||||
badges: [],
|
||||
stats: {}
|
||||
})
|
||||
]
|
||||
|
||||
return { edgeCases }
|
||||
},
|
||||
template: `
|
||||
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-gray-900">
|
||||
<AssetCard
|
||||
v-for="asset in edgeCases"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:interactive="true"
|
||||
@select="(asset) => console.log('Selected:', asset)"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'All AssetCard edge cases in a grid layout to test graceful handling of missing data, badges, stats, and long descriptions.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/platform/assets/components/AssetCard.vue
Normal file
111
src/platform/assets/components/AssetCard.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<component
|
||||
:is="interactive ? 'button' : 'div'"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
v-bind="elementProps"
|
||||
:class="
|
||||
cn(
|
||||
// Base layout and container styles (always applied)
|
||||
'rounded-xl overflow-hidden transition-all duration-200',
|
||||
// Button-specific styles
|
||||
interactive && [
|
||||
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
|
||||
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
|
||||
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
|
||||
],
|
||||
// Div-specific styles
|
||||
!interactive && [
|
||||
'bg-ivory-100 border border-gray-300',
|
||||
'dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600'
|
||||
]
|
||||
)
|
||||
"
|
||||
@click="interactive && $emit('select', asset)"
|
||||
@keydown.enter="interactive && $emit('select', asset)"
|
||||
>
|
||||
<div class="relative w-full aspect-square overflow-hidden">
|
||||
<div
|
||||
class="w-full h-full bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600 flex items-center justify-center"
|
||||
></div>
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
</div>
|
||||
<div class="p-4 h-32 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
'text-slate-800',
|
||||
'dark-theme:text-white'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ asset.name }}
|
||||
</h3>
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||
'text-stone-300',
|
||||
'dark-theme:text-stone-200'
|
||||
)
|
||||
"
|
||||
:title="asset.description"
|
||||
>
|
||||
{{ asset.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex gap-4 text-xs',
|
||||
'text-stone-400',
|
||||
'dark-theme:text-stone-300'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span v-if="asset.stats.stars" class="flex items-center gap-1">
|
||||
<i-lucide:star class="size-3" />
|
||||
{{ asset.stats.stars }}
|
||||
</span>
|
||||
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
|
||||
<i-lucide:download class="size-3" />
|
||||
{{ asset.stats.downloadCount }}
|
||||
</span>
|
||||
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
|
||||
<i-lucide:clock class="size-3" />
|
||||
{{ asset.stats.formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
asset: AssetDisplayItem
|
||||
interactive?: boolean
|
||||
}>()
|
||||
|
||||
const elementProps = computed(() =>
|
||||
props.interactive
|
||||
? {
|
||||
type: 'button',
|
||||
'aria-label': `Select asset ${props.asset.name}`
|
||||
}
|
||||
: {}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
select: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
</script>
|
||||
102
src/platform/assets/components/AssetFilterBar.vue
Normal file
102
src/platform/assets/components/AssetFilterBar.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div :class="containerClasses" data-component-id="asset-filter-bar">
|
||||
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
||||
<MultiSelect
|
||||
v-model="fileFormats"
|
||||
label="File formats"
|
||||
:options="fileFormatOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-file-formats"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="baseModels"
|
||||
label="Base models"
|
||||
:options="baseModelOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-base-models"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
label="Sort by"
|
||||
:options="sortOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-sort"
|
||||
@update:model-value="handleFilterChange"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:arrow-up-down class="size-3" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface FilterState {
|
||||
fileFormats: string[]
|
||||
baseModels: string[]
|
||||
sortBy: string
|
||||
}
|
||||
|
||||
const fileFormats = ref<SelectOption[]>([])
|
||||
const baseModels = ref<SelectOption[]>([])
|
||||
const sortBy = ref('name-asc')
|
||||
|
||||
// TODO: Make fileFormatOptions configurable via props or assetService
|
||||
// Should support dynamic file formats based on available assets or server capabilities
|
||||
const fileFormatOptions = [
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.safetensors', value: 'safetensors' },
|
||||
{ name: '.pt', value: 'pt' }
|
||||
]
|
||||
|
||||
// TODO: Make baseModelOptions configurable via props or assetService
|
||||
// Should support dynamic base models based on available assets or server detection
|
||||
const baseModelOptions = [
|
||||
{ name: 'SD 1.5', value: 'sd15' },
|
||||
{ name: 'SD XL', value: 'sdxl' },
|
||||
{ name: 'SD 3.5', value: 'sd35' }
|
||||
]
|
||||
|
||||
// TODO: Make sortOptions configurable via props
|
||||
// Different asset types might need different sorting options
|
||||
const sortOptions = [
|
||||
{ name: 'A-Z', value: 'name-asc' },
|
||||
{ name: 'Z-A', value: 'name-desc' },
|
||||
{ name: 'Recent', value: 'recent' },
|
||||
{ name: 'Popular', value: 'popular' }
|
||||
]
|
||||
|
||||
const emit = defineEmits<{
|
||||
filterChange: [filters: FilterState]
|
||||
}>()
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex gap-4 items-center justify-between',
|
||||
'px-6 pt-2 pb-6'
|
||||
)
|
||||
const leftSideClasses = cn('flex gap-4 items-center')
|
||||
const rightSideClasses = cn('flex items-center')
|
||||
const selectClasses = cn('min-w-32')
|
||||
|
||||
function handleFilterChange() {
|
||||
emit('filterChange', {
|
||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||
sortBy: sortBy.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
70
src/platform/assets/components/AssetGrid.vue
Normal file
70
src/platform/assets/components/AssetGrid.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div
|
||||
data-component-id="AssetGrid"
|
||||
:style="gridStyle"
|
||||
role="grid"
|
||||
aria-label="Asset collection"
|
||||
:aria-rowcount="-1"
|
||||
:aria-colcount="-1"
|
||||
:aria-setsize="assets.length"
|
||||
>
|
||||
<AssetCard
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:interactive="true"
|
||||
role="gridcell"
|
||||
@select="$emit('assetSelect', $event)"
|
||||
/>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="assets.length === 0"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full flex flex-col items-center justify-center py-16',
|
||||
'text-stone-300 dark-theme:text-stone-200'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i-lucide:search class="size-10 mb-4" />
|
||||
<h3 class="text-lg font-medium mb-2">
|
||||
{{ $t('assetBrowser.noAssetsFound') }}
|
||||
</h3>
|
||||
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="col-span-full flex items-center justify-center py-16"
|
||||
>
|
||||
<i-lucide:loader
|
||||
:class="
|
||||
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
assets: AssetDisplayItem[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
assetSelect: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
// Use same grid style as BaseModalLayout
|
||||
const gridStyle = computed(() => createGridStyle())
|
||||
</script>
|
||||
188
src/platform/assets/composables/useAssetBrowser.ts
Normal file
188
src/platform/assets/composables/useAssetBrowser.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
getAssetBaseModel,
|
||||
getAssetDescription
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
type AssetBadge = {
|
||||
label: string
|
||||
type: 'type' | 'base' | 'size'
|
||||
}
|
||||
|
||||
// Display properties for transformed assets
|
||||
export interface AssetDisplayItem extends AssetItem {
|
||||
description: string
|
||||
formattedSize: string
|
||||
badges: AssetBadge[]
|
||||
stats: {
|
||||
formattedDate?: string
|
||||
downloadCount?: string
|
||||
stars?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset Browser composable
|
||||
* Manages search, filtering, asset transformation and selection logic
|
||||
*/
|
||||
export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
// State
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('all')
|
||||
const sortBy = ref('name')
|
||||
|
||||
// Transform API asset to display asset
|
||||
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
||||
// Extract description from metadata or create from tags
|
||||
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||
const description =
|
||||
getAssetDescription(asset) ||
|
||||
`${typeTag || t('assetBrowser.unknown')} model`
|
||||
|
||||
// Format file size
|
||||
const formattedSize = formatSize(asset.size)
|
||||
|
||||
// Create badges from tags and metadata
|
||||
const badges: AssetBadge[] = []
|
||||
|
||||
// Type badge from non-root tag
|
||||
if (typeTag) {
|
||||
badges.push({ label: typeTag, type: 'type' })
|
||||
}
|
||||
|
||||
// Base model badge from metadata
|
||||
const baseModel = getAssetBaseModel(asset)
|
||||
if (baseModel) {
|
||||
badges.push({
|
||||
label: baseModel,
|
||||
type: 'base'
|
||||
})
|
||||
}
|
||||
|
||||
// Size badge
|
||||
badges.push({ label: formattedSize, type: 'size' })
|
||||
|
||||
// Create display stats from API data
|
||||
const stats = {
|
||||
formattedDate: new Date(asset.created_at).toLocaleDateString(),
|
||||
downloadCount: undefined, // Not available in API
|
||||
stars: undefined // Not available in API
|
||||
}
|
||||
|
||||
return {
|
||||
...asset,
|
||||
description,
|
||||
formattedSize,
|
||||
badges,
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
// Extract available categories from assets
|
||||
const availableCategories = computed(() => {
|
||||
const categorySet = new Set<string>()
|
||||
|
||||
assets.forEach((asset) => {
|
||||
// Second tag is the category (after 'models' root tag)
|
||||
if (asset.tags.length > 1 && asset.tags[0] === 'models') {
|
||||
categorySet.add(asset.tags[1])
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('assetBrowser.allModels'),
|
||||
icon: 'icon-[lucide--folder]'
|
||||
},
|
||||
...Array.from(categorySet)
|
||||
.sort()
|
||||
.map((category) => ({
|
||||
id: category,
|
||||
label: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
icon: 'icon-[lucide--package]'
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
// Compute content title from selected category
|
||||
const contentTitle = computed(() => {
|
||||
if (selectedCategory.value === 'all') {
|
||||
return t('assetBrowser.allModels')
|
||||
}
|
||||
|
||||
const category = availableCategories.value.find(
|
||||
(cat) => cat.id === selectedCategory.value
|
||||
)
|
||||
return category?.label || t('assetBrowser.assets')
|
||||
})
|
||||
|
||||
// Filter functions
|
||||
const filterByCategory = (category: string) => (asset: AssetItem) => {
|
||||
if (category === 'all') return true
|
||||
return asset.tags.includes(category)
|
||||
}
|
||||
|
||||
const filterByQuery = (query: string) => (asset: AssetItem) => {
|
||||
if (!query) return true
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const description = getAssetDescription(asset)
|
||||
return (
|
||||
asset.name.toLowerCase().includes(lowerQuery) ||
|
||||
(description && description.toLowerCase().includes(lowerQuery)) ||
|
||||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||
)
|
||||
}
|
||||
|
||||
// Computed filtered and transformed assets
|
||||
const filteredAssets = computed(() => {
|
||||
const filtered = assets
|
||||
.filter(filterByCategory(selectedCategory.value))
|
||||
.filter(filterByQuery(searchQuery.value))
|
||||
|
||||
// Sort assets
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy.value) {
|
||||
case 'date':
|
||||
return (
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
case 'name':
|
||||
default:
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
})
|
||||
|
||||
// Transform to display format
|
||||
return filtered.map(transformAssetForDisplay)
|
||||
})
|
||||
|
||||
// Actions
|
||||
function selectAsset(asset: AssetDisplayItem): UUID {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Asset selected:', asset.id, asset.name)
|
||||
}
|
||||
return asset.id
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
sortBy,
|
||||
|
||||
// Computed
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
|
||||
// Actions
|
||||
selectAsset,
|
||||
transformAssetForDisplay
|
||||
}
|
||||
}
|
||||
203
src/platform/assets/composables/useAssetBrowserDialog.stories.ts
Normal file
203
src/platform/assets/composables/useAssetBrowserDialog.stories.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
// Component that simulates the useAssetBrowserDialog functionality with working close
|
||||
const DialogDemoComponent = {
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const isDialogOpen = ref(false)
|
||||
const currentNodeType = ref('CheckpointLoaderSimple')
|
||||
const currentInputName = ref('ckpt_name')
|
||||
const currentValue = ref('')
|
||||
|
||||
const handleOpenDialog = (
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
value = ''
|
||||
) => {
|
||||
currentNodeType.value = nodeType
|
||||
currentInputName.value = inputName
|
||||
currentValue.value = value
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
isDialogOpen.value = false
|
||||
}
|
||||
|
||||
const handleAssetSelected = (assetPath: string) => {
|
||||
console.log('Asset selected:', assetPath)
|
||||
alert(`Selected asset: ${assetPath}`)
|
||||
isDialogOpen.value = false // Auto-close like the real composable
|
||||
}
|
||||
|
||||
const handleOpenWithCurrentValue = () => {
|
||||
handleOpenDialog(
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
'realistic_vision_v5.safetensors'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
isDialogOpen,
|
||||
currentNodeType,
|
||||
currentInputName,
|
||||
currentValue,
|
||||
handleOpenDialog,
|
||||
handleOpenWithCurrentValue,
|
||||
handleCloseDialog,
|
||||
handleAssetSelected,
|
||||
mockAssets
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="relative">
|
||||
<div class="p-8 space-y-4">
|
||||
<h2 class="text-2xl font-bold mb-6">Asset Browser Dialog Demo</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">Different Node Types</h3>
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<button
|
||||
@click="handleOpenDialog('CheckpointLoaderSimple', 'ckpt_name')"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Browse Checkpoints
|
||||
</button>
|
||||
<button
|
||||
@click="handleOpenDialog('VAELoader', 'vae_name')"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Browse VAE
|
||||
</button>
|
||||
<button
|
||||
@click="handleOpenDialog('ControlNetLoader', 'control_net_name')"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Browse ControlNet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">With Current Value</h3>
|
||||
<button
|
||||
@click="handleOpenWithCurrentValue"
|
||||
class="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700"
|
||||
>
|
||||
Change Current Model
|
||||
</button>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Opens with "realistic_vision_v5.safetensors" as current value
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-4 bg-gray-100 rounded">
|
||||
<h4 class="font-semibold mb-2">Instructions:</h4>
|
||||
<ul class="text-sm space-y-1">
|
||||
<li>• Click any button to open the Asset Browser dialog</li>
|
||||
<li>• Select an asset to see the callback in action</li>
|
||||
<li>• Check the browser console for logged events</li>
|
||||
<li>• Try toggling the left panel with different asset types</li>
|
||||
<li>• Close button will work properly in this demo</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Modal Overlay -->
|
||||
<div
|
||||
v-if="isDialogOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="handleCloseDialog"
|
||||
>
|
||||
<div class="w-[80vw] h-[80vh] max-w-[80vw] max-h-[80vh] rounded-2xl overflow-hidden">
|
||||
<AssetBrowserModal
|
||||
:assets="mockAssets"
|
||||
:node-type="currentNodeType"
|
||||
:input-name="currentInputName"
|
||||
:current-value="currentValue"
|
||||
@asset-select="handleAssetSelected"
|
||||
@close="handleCloseDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Platform/Assets/useAssetBrowserDialog',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Demonstrates the AssetBrowserModal functionality as used by the useAssetBrowserDialog composable.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Demo: Story = {
|
||||
render: () => ({
|
||||
components: { DialogDemoComponent },
|
||||
template: `
|
||||
<div>
|
||||
<DialogDemoComponent />
|
||||
|
||||
<!-- Code Example Section -->
|
||||
<div class="p-8 border-t border-gray-200 bg-gray-50">
|
||||
<h2 class="text-2xl font-bold mb-4">Code Example</h2>
|
||||
<p class="text-gray-600 mb-4">
|
||||
This is how you would use the composable in your component:
|
||||
</p>
|
||||
<div class="bg-white p-4 rounded-lg border shadow-sm">
|
||||
<pre><code class="text-sm">import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
|
||||
const openBrowser = () => {
|
||||
assetBrowserDialog.show({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
onAssetSelected: (assetPath) => {
|
||||
console.log('Selected:', assetPath)
|
||||
// Update your component state
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { openBrowser }
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>💡 Try it:</strong> Use the interactive buttons above to see this code in action!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/platform/assets/composables/useAssetBrowserDialog.ts
Normal file
66
src/platform/assets/composables/useAssetBrowserDialog.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
interface AssetBrowserDialogProps {
|
||||
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
|
||||
nodeType: string
|
||||
/** Widget input name (e.g., 'ckpt_name') */
|
||||
inputName: string
|
||||
/** Current selected asset value */
|
||||
currentValue?: string
|
||||
/** Callback for when an asset is selected */
|
||||
onAssetSelected?: (assetPath: string) => void
|
||||
}
|
||||
|
||||
export const useAssetBrowserDialog = () => {
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogKey = 'global-asset-browser'
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
|
||||
function show(props: AssetBrowserDialogProps) {
|
||||
const handleAssetSelected = (assetPath: string) => {
|
||||
props.onAssetSelected?.(assetPath)
|
||||
hide() // Auto-close on selection
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
hide()
|
||||
}
|
||||
|
||||
// Default dialog configuration for AssetBrowserModal
|
||||
const dialogComponentProps = {
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: false,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden'
|
||||
},
|
||||
header: {
|
||||
class: 'p-0 hidden'
|
||||
},
|
||||
content: {
|
||||
class: 'p-0 m-0 h-full w-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: dialogKey,
|
||||
component: AssetBrowserModal,
|
||||
props: {
|
||||
nodeType: props.nodeType,
|
||||
inputName: props.inputName,
|
||||
currentValue: props.currentValue,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: handleClose
|
||||
},
|
||||
dialogComponentProps
|
||||
})
|
||||
}
|
||||
|
||||
return { show, hide }
|
||||
}
|
||||
56
src/platform/assets/composables/useAssetFilterOptions.ts
Normal file
56
src/platform/assets/composables/useAssetFilterOptions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { uniqWith } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Composable that extracts available filter options from asset data
|
||||
* Provides reactive computed properties for file formats and base models
|
||||
*/
|
||||
export function useAssetFilterOptions(assets: AssetItem[] = []) {
|
||||
/**
|
||||
* Extract unique file formats from asset names
|
||||
* Returns sorted SelectOption array with extensions
|
||||
*/
|
||||
const availableFileFormats = computed<SelectOption[]>(() => {
|
||||
const extensions = assets
|
||||
.map((asset) => {
|
||||
const extension = asset.name.split('.').pop()
|
||||
return extension && extension !== asset.name ? extension : null
|
||||
})
|
||||
.filter((extension): extension is string => extension !== null)
|
||||
|
||||
const uniqueExtensions = uniqWith(extensions, (a, b) => a === b)
|
||||
|
||||
return uniqueExtensions.sort().map((format) => ({
|
||||
name: `.${format}`,
|
||||
value: format
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Extract unique base models from asset user metadata
|
||||
* Returns sorted SelectOption array with base model names
|
||||
*/
|
||||
const availableBaseModels = computed<SelectOption[]>(() => {
|
||||
const models = assets
|
||||
.map((asset) => asset.user_metadata?.base_model)
|
||||
.filter(
|
||||
(baseModel): baseModel is string =>
|
||||
baseModel !== undefined && typeof baseModel === 'string'
|
||||
)
|
||||
|
||||
const uniqueModels = uniqWith(models, (a, b) => a === b)
|
||||
|
||||
return uniqueModels.sort().map((model) => ({
|
||||
name: model,
|
||||
value: model
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
availableFileFormats,
|
||||
availableBaseModels
|
||||
}
|
||||
}
|
||||
128
src/platform/assets/fixtures/ui-mock-assets.ts
Normal file
128
src/platform/assets/fixtures/ui-mock-assets.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// 🎭 OBVIOUSLY FAKE MOCK DATA - DO NOT USE IN PRODUCTION! 🎭
|
||||
const fakeFunnyModelNames = [
|
||||
'🎯_totally_real_model_v420.69',
|
||||
'🚀_definitely_not_fake_v999',
|
||||
'🎪_super_legit_checkpoint_pro_max',
|
||||
'🦄_unicorn_dreams_totally_real.model',
|
||||
'🍕_pizza_generator_supreme',
|
||||
'🎸_rock_star_fake_data_v1337',
|
||||
'🌮_taco_tuesday_model_deluxe',
|
||||
'🦖_dino_nugget_generator_v3',
|
||||
'🎮_gamer_fuel_checkpoint_xl',
|
||||
'🍄_mushroom_kingdom_diffusion',
|
||||
'🏴☠️_pirate_treasure_model_arr',
|
||||
'🦋_butterfly_effect_generator',
|
||||
'🎺_jazz_hands_checkpoint_pro',
|
||||
'🥨_pretzel_logic_model_v2',
|
||||
'🌙_midnight_snack_generator',
|
||||
'🎭_drama_llama_checkpoint',
|
||||
'🧙♀️_wizard_hat_diffusion_xl',
|
||||
'🎪_circus_peanut_model_v4',
|
||||
'🦒_giraffe_neck_generator',
|
||||
'🎲_random_stuff_checkpoint_max'
|
||||
]
|
||||
|
||||
const obviouslyFakeDescriptions = [
|
||||
'⚠️ FAKE DATA: Generates 100% authentic fake images with premium mock quality',
|
||||
'🎭 MOCK ALERT: This totally real model creates absolutely genuine fake content',
|
||||
'🚨 NOT REAL: Professional-grade fake imagery for your mock data needs',
|
||||
'🎪 DEMO ONLY: Circus-quality fake generation with extra mock seasoning',
|
||||
'🍕 FAKE FOOD: Generates delicious fake pizzas (not edible in reality)',
|
||||
"🎸 MOCK ROCK: Creates fake rock stars who definitely don't exist",
|
||||
'🌮 TACO FAKERY: Tuesday-themed fake tacos for your mock appetite',
|
||||
'🦖 PREHISTORIC FAKE: Generates extinct fake dinosaurs for demo purposes',
|
||||
'🎮 FAKE GAMING: Level up your mock data with obviously fake content',
|
||||
'🍄 FUNGI FICTION: Magical fake mushrooms from the demo dimension',
|
||||
'🏴☠️ FAKE TREASURE: Arr! This be mock data for ye demo needs, matey!',
|
||||
'🦋 DEMO EFFECT: Small fake changes create big mock differences',
|
||||
'🎺 JAZZ FAKERY: Smooth fake jazz for your mock listening pleasure',
|
||||
'🥨 MOCK LOGIC: Twisted fake reasoning for your demo requirements',
|
||||
'🌙 MIDNIGHT MOCK: Late-night fake snacks for your demo hunger',
|
||||
'🎭 FAKE DRAMA: Over-the-top mock emotions for demo entertainment',
|
||||
'🧙♀️ WIZARD MOCK: Magically fake spells cast with demo ingredients',
|
||||
'🎪 CIRCUS FAKE: Big top mock entertainment under the demo tent',
|
||||
'🦒 TALL FAKE: Reaches new heights of obviously fake content',
|
||||
'🎲 RANDOM MOCK: Generates random fake stuff for your demo pleasure'
|
||||
]
|
||||
|
||||
// API-compliant tag structure: first tag must be root (models/input/output), second is category
|
||||
const modelCategories = ['checkpoints', 'loras', 'embeddings', 'vae']
|
||||
const baseModels = ['sd15', 'sdxl', 'sd35']
|
||||
const fileExtensions = ['.safetensors', '.ckpt', '.pt']
|
||||
const mimeTypes = [
|
||||
'application/octet-stream',
|
||||
'application/x-pytorch',
|
||||
'application/x-safetensors'
|
||||
]
|
||||
|
||||
function getRandomElement<T>(array: T[]): T {
|
||||
return array[Math.floor(Math.random() * array.length)]
|
||||
}
|
||||
|
||||
function getRandomNumber(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
function getRandomISODate(): string {
|
||||
const start = new Date('2024-01-01').getTime()
|
||||
const end = new Date('2024-12-31').getTime()
|
||||
const randomTime = start + Math.random() * (end - start)
|
||||
return new Date(randomTime).toISOString()
|
||||
}
|
||||
|
||||
function generateFakeAssetHash(): string {
|
||||
const chars = '0123456789abcdef'
|
||||
let hash = 'blake3:'
|
||||
for (let i = 0; i < 64; i++) {
|
||||
hash += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// 🎭 CREATES OBVIOUSLY FAKE ASSETS FOR DEMO/TEST PURPOSES ONLY! 🎭
|
||||
export function createMockAssets(count: number = 20): AssetItem[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const category = getRandomElement(modelCategories)
|
||||
const baseModel = getRandomElement(baseModels)
|
||||
const extension = getRandomElement(fileExtensions)
|
||||
const mimeType = getRandomElement(mimeTypes)
|
||||
const sizeInBytes = getRandomNumber(
|
||||
500 * 1024 * 1024,
|
||||
8 * 1024 * 1024 * 1024
|
||||
) // 500MB to 8GB
|
||||
const createdAt = getRandomISODate()
|
||||
const updatedAt = createdAt
|
||||
const lastAccessTime = getRandomISODate()
|
||||
|
||||
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
|
||||
|
||||
return {
|
||||
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
|
||||
name: fakeFileName,
|
||||
asset_hash: generateFakeAssetHash(),
|
||||
size: sizeInBytes,
|
||||
mime_type: mimeType,
|
||||
tags: [
|
||||
'models', // Root tag (required first)
|
||||
category, // Category tag (required second for models)
|
||||
'fake-data', // Obviously fake tag
|
||||
...(Math.random() > 0.5 ? ['demo-mode'] : ['test-only']),
|
||||
...(Math.random() > 0.7 ? ['obviously-mock'] : [])
|
||||
],
|
||||
preview_url: `/api/assets/mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake/content`,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
last_access_time: lastAccessTime,
|
||||
user_metadata: {
|
||||
description: obviouslyFakeDescriptions[index],
|
||||
base_model: baseModel,
|
||||
original_name: fakeFunnyModelNames[index],
|
||||
warning: '🚨 THIS IS FAKE DEMO DATA - NOT A REAL MODEL! 🚨'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const mockAssets = createMockAssets(20)
|
||||
@@ -1,12 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Zod schemas for asset API validation
|
||||
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
|
||||
const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
asset_hash: z.string(),
|
||||
size: z.number(),
|
||||
created_at: z.string().optional()
|
||||
mime_type: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
preview_url: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
last_access_time: z.string(),
|
||||
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
preview_id: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const zAssetResponse = z.object({
|
||||
@@ -20,19 +27,22 @@ const zModelFolder = z.object({
|
||||
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 const assetResponseSchema = zAssetResponse
|
||||
|
||||
// Export types derived from Zod schemas
|
||||
export type AssetItem = z.infer<typeof zAsset>
|
||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
// Common interfaces for API responses
|
||||
export interface ModelFile {
|
||||
name: string
|
||||
pathIndex: number
|
||||
}
|
||||
|
||||
// Legacy interface for backward compatibility (now aligned with Zod schema)
|
||||
export interface ModelFolderInfo {
|
||||
name: string
|
||||
folders: string[]
|
||||
|
||||
@@ -67,7 +67,7 @@ function createAssetService() {
|
||||
)
|
||||
|
||||
// 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
|
||||
const discoveredFolders = new Set<string>(
|
||||
@@ -75,7 +75,7 @@ function createAssetService() {
|
||||
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
|
||||
?.flatMap((asset) => asset.tags)
|
||||
?.filter(
|
||||
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
|
||||
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
|
||||
) ?? []
|
||||
)
|
||||
|
||||
|
||||
27
src/platform/assets/utils/assetMetadataUtils.ts
Normal file
27
src/platform/assets/utils/assetMetadataUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Type-safe utilities for extracting metadata from assets
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely extracts string description from asset metadata
|
||||
* @param asset - The asset to extract description from
|
||||
* @returns The description string or null if not present/not a string
|
||||
*/
|
||||
export function getAssetDescription(asset: AssetItem): string | null {
|
||||
return typeof asset.user_metadata?.description === 'string'
|
||||
? asset.user_metadata.description
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts string base_model from asset metadata
|
||||
* @param asset - The asset to extract base_model from
|
||||
* @returns The base_model string or null if not present/not a string
|
||||
*/
|
||||
export function getAssetBaseModel(asset: AssetItem): string | null {
|
||||
return typeof asset.user_metadata?.base_model === 'string'
|
||||
? asset.user_metadata.base_model
|
||||
: null
|
||||
}
|
||||
Reference in New Issue
Block a user