diff --git a/.storybook/main.ts b/.storybook/main.ts
index a799ec143..3869df31c 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -3,7 +3,7 @@ import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
-import type { InlineConfig } from 'vite'
+import type { InlineConfig, Plugin } from 'vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
@@ -18,8 +18,14 @@ const config: StorybookConfig = {
// Filter out any plugins that might generate import maps
if (config.plugins) {
- config.plugins = config.plugins.filter((plugin: any) => {
- if (plugin && plugin.name && plugin.name.includes('import-map')) {
+ config.plugins = (config.plugins as Plugin[]).filter((plugin: Plugin) => {
+ if (
+ plugin &&
+ typeof plugin === 'object' &&
+ 'name' in plugin &&
+ typeof plugin.name === 'string' &&
+ plugin.name.includes('import-map')
+ ) {
return false
}
return true
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index 747bbe802..ad8e61d99 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -1,7 +1,7 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
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 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -10,10 +10,9 @@ import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import '../src/assets/css/style.css'
+import GlobalDialog from '../src/components/dialog/GlobalDialog.vue'
import { i18n } from '../src/i18n'
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, {
semantic: {
@@ -25,12 +24,14 @@ const ComfyUIPreset = definePreset(Aura, {
// Setup Vue app for Storybook
setup((app) => {
app.directive('tooltip', Tooltip)
+
+ // Create Pinia instance
const pinia = createPinia()
+
app.use(pinia)
- // Initialize stores
- useColorPaletteStore(pinia)
- useWidgetStore(pinia)
+ // Register global components for dialogs
+ app.component('GlobalDialog', GlobalDialog)
app.use(i18n)
app.use(PrimeVue, {
@@ -50,8 +51,8 @@ setup((app) => {
app.use(ToastService)
})
-// Dark theme decorator
-export const withTheme = (Story: any, context: any) => {
+// Theme and dialog decorator
+export const withTheme = (Story: StoryFn, context: StoryContext) => {
const theme = context.globals.theme || 'light'
// Apply theme class to document root
@@ -63,7 +64,19 @@ export const withTheme = (Story: any, context: any) => {
document.body.classList.remove('dark-theme')
}
- return Story()
+ // Return story with GlobalDialog included
+ return {
+ components: { GlobalDialog },
+ setup() {
+ return { storyResult: Story(context.args, context) }
+ },
+ template: `
+
+
+
+
+ `
+ }
}
const preview: Preview = {
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index e70301ce7..d880c1ba8 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -1862,5 +1862,14 @@
"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"
}
}
diff --git a/src/platform/assets/components/AssetBadgeGroup.vue b/src/platform/assets/components/AssetBadgeGroup.vue
new file mode 100644
index 000000000..73194743d
--- /dev/null
+++ b/src/platform/assets/components/AssetBadgeGroup.vue
@@ -0,0 +1,42 @@
+
+
+
+ {{ badge.label }}
+
+
+
+
+
diff --git a/src/platform/assets/components/AssetBrowserModal.stories.ts b/src/platform/assets/components/AssetBrowserModal.stories.ts
new file mode 100644
index 000000000..25d4916c7
--- /dev/null
+++ b/src/platform/assets/components/AssetBrowserModal.stories.ts
@@ -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 = {
+ 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
+
+// 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: `
+
+ `
+ })
+}
+
+// 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: `
+
+ `
+ }),
+ 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: `
+
+ `
+ }),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Modal with left panel explicitly disabled via showLeftPanel=false.'
+ }
+ }
+ }
+}
diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue
new file mode 100644
index 000000000..188daaee3
--- /dev/null
+++ b/src/platform/assets/components/AssetBrowserModal.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+ {{ $t('assetBrowser.browseAssets') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/assets/components/AssetCard.stories.ts b/src/platform/assets/components/AssetCard.stories.ts
new file mode 100644
index 000000000..ffce30d55
--- /dev/null
+++ b/src/platform/assets/components/AssetCard.stories.ts
@@ -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 => ({
+ ...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 = {
+ title: 'Platform/Assets/AssetCard',
+ component: AssetCard,
+ parameters: {
+ layout: 'centered'
+ },
+ decorators: [
+ () => ({
+ template:
+ '
'
+ })
+ ]
+}
+
+export default meta
+type Story = StoryObj
+
+// 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: `
+
+
console.log('Selected:', asset)"
+ />
+
+ `
+ }),
+ 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.'
+ }
+ }
+ }
+}
diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue
new file mode 100644
index 000000000..3a2458954
--- /dev/null
+++ b/src/platform/assets/components/AssetCard.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+ {{ asset.name }}
+
+
+ {{ asset.description }}
+
+
+
+
+ {{ asset.stats.stars }}
+
+
+
+ {{ asset.stats.downloadCount }}
+
+
+
+ {{ asset.stats.formattedDate }}
+
+
+
+
+
+
+
diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue
new file mode 100644
index 000000000..66dbe9c45
--- /dev/null
+++ b/src/platform/assets/components/AssetFilterBar.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/assets/components/AssetGrid.vue b/src/platform/assets/components/AssetGrid.vue
new file mode 100644
index 000000000..fe7dbb840
--- /dev/null
+++ b/src/platform/assets/components/AssetGrid.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+ {{ $t('assetBrowser.noAssetsFound') }}
+
+
{{ $t('assetBrowser.tryAdjustingFilters') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts
new file mode 100644
index 000000000..5c0d8ead2
--- /dev/null
+++ b/src/platform/assets/composables/useAssetBrowser.ts
@@ -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()
+
+ 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
+ }
+}
diff --git a/src/platform/assets/composables/useAssetBrowserDialog.stories.ts b/src/platform/assets/composables/useAssetBrowserDialog.stories.ts
new file mode 100644
index 000000000..784edafb9
--- /dev/null
+++ b/src/platform/assets/composables/useAssetBrowserDialog.stories.ts
@@ -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: `
+
+
+
Asset Browser Dialog Demo
+
+
+
+
Different Node Types
+
+
+
+
+
+
+
+
+
With Current Value
+
+
+ Opens with "realistic_vision_v5.safetensors" as current value
+
+
+
+
+
Instructions:
+
+ - • Click any button to open the Asset Browser dialog
+ - • Select an asset to see the callback in action
+ - • Check the browser console for logged events
+ - • Try toggling the left panel with different asset types
+ - • Close button will work properly in this demo
+
+
+
+
+
+
+
+
+ `
+}
+
+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
+
+export const Demo: Story = {
+ render: () => ({
+ components: { DialogDemoComponent },
+ template: `
+
+
+
+
+
+
Code Example
+
+ This is how you would use the composable in your component:
+
+
+
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 }
+ }
+}
+
+
+
+ 💡 Try it: Use the interactive buttons above to see this code in action!
+
+
+
+
+ `
+ }),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.'
+ }
+ }
+ }
+}
diff --git a/src/platform/assets/composables/useAssetBrowserDialog.ts b/src/platform/assets/composables/useAssetBrowserDialog.ts
new file mode 100644
index 000000000..dfacd83eb
--- /dev/null
+++ b/src/platform/assets/composables/useAssetBrowserDialog.ts
@@ -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 }
+}
diff --git a/src/platform/assets/fixtures/ui-mock-assets.ts b/src/platform/assets/fixtures/ui-mock-assets.ts
new file mode 100644
index 000000000..6c7284386
--- /dev/null
+++ b/src/platform/assets/fixtures/ui-mock-assets.ts
@@ -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(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)
diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts
index 277efcbb0..1927cd9c2 100644
--- a/src/platform/assets/schemas/assetSchema.ts
+++ b/src/platform/assets/schemas/assetSchema.ts
@@ -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.any()).optional(),
+ 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
export type AssetResponse = z.infer
export type ModelFolder = z.infer
+export type ModelFile = z.infer
-// 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[]
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts
index 344209bf7..74b20a753 100644
--- a/src/platform/assets/services/assetService.ts
+++ b/src/platform/assets/services/assetService.ts
@@ -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(
@@ -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)
) ?? []
)
diff --git a/tests-ui/platform/assets/components/AssetBrowserModal.test.ts b/tests-ui/platform/assets/components/AssetBrowserModal.test.ts
new file mode 100644
index 000000000..0d110b60d
--- /dev/null
+++ b/tests-ui/platform/assets/components/AssetBrowserModal.test.ts
@@ -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: `
+
+ `
+ }
+}))
+
+vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
+ default: {
+ name: 'BaseModalLayout',
+ props: ['contentTitle'],
+ emits: ['close'],
+ template: `
+
+ `
+ }
+}))
+
+vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
+ default: {
+ name: 'LeftSidePanel',
+ props: ['modelValue', 'navItems'],
+ emits: ['update:modelValue'],
+ template: `
+
+
+
+ `
+ }
+}))
+
+vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
+ default: {
+ name: 'AssetGrid',
+ props: ['assets'],
+ emits: ['asset-select'],
+ template: `
+
+
+ {{ asset.name }}
+
+
+ No assets found
+
+
+ `
+ }
+}))
+
+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 = {}
+ ) => {
+ const pinia = createPinia()
+ setActivePinia(pinia)
+
+ wrapper = mount(AssetBrowserModal, {
+ props: {
+ assets: assets,
+ ...props
+ },
+ global: {
+ plugins: [pinia],
+ stubs: {
+ 'i-lucide:folder': {
+ template: ''
+ }
+ },
+ 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)
+ })
+ })
+})
diff --git a/tests-ui/platform/assets/components/AssetFilterBar.test.ts b/tests-ui/platform/assets/components/AssetFilterBar.test.ts
new file mode 100644
index 000000000..db24070e1
--- /dev/null
+++ b/tests-ui/platform/assets/components/AssetFilterBar.test.ts
@@ -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: `
+
+
+
+ `
+ }
+}))
+
+vi.mock('@/components/input/SingleSelect.vue', () => ({
+ default: {
+ name: 'SingleSelect',
+ props: {
+ modelValue: String,
+ label: String,
+ options: Array,
+ class: String
+ },
+ emits: ['update:modelValue'],
+ template: `
+
+
+
+ `
+ }
+}))
+
+// 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
+ )
+ })
+ })
+})
diff --git a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts
new file mode 100644
index 000000000..cf2b410a1
--- /dev/null
+++ b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts
@@ -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 => ({
+ 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')
+ })
+ })
+})
diff --git a/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts b/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts
new file mode 100644
index 000000000..029bf3148
--- /dev/null
+++ b/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts
@@ -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
+let mockCloseDialog: ReturnType
+
+vi.mock('@/stores/dialogStore', () => ({
+ useDialogStore: vi.fn(() => ({
+ showDialog: mockShowDialog,
+ closeDialog: mockCloseDialog
+ }))
+}))
+
+// Test factory functions
+interface AssetBrowserProps {
+ nodeType: string
+ inputName: string
+ onAssetSelected?: ReturnType
+}
+
+function createAssetBrowserProps(
+ overrides: Partial = {}
+): AssetBrowserProps {
+ return {
+ nodeType: 'CheckpointLoaderSimple',
+ inputName: 'ckpt_name',
+ ...overrides
+ }
+}
+
+describe('useAssetBrowserDialog', () => {
+ let assetBrowserDialog: ReturnType
+
+ 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'
+ })
+ })
+ })
+})
diff --git a/tests-ui/tests/services/assetService.test.ts b/tests-ui/tests/services/assetService.test.ts
index f11c9d40d..d96ef765b 100644
--- a/tests-ui/tests/services/assetService.test.ts
+++ b/tests-ui/tests/services/assetService.test.ts
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
@@ -17,26 +18,39 @@ vi.mock('@/stores/modelToNodeStore', () => ({
}))
}))
+// Helper to create API-compliant test assets
+function createTestAsset(overrides: Partial = {}) {
+ 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
const MOCK_ASSETS = {
- checkpoints: {
+ checkpoints: createTestAsset({
id: 'uuid-1',
name: 'model1.safetensors',
- tags: ['models', 'checkpoints'],
- size: 123456
- },
- loras: {
+ tags: ['models', 'checkpoints']
+ }),
+ loras: createTestAsset({
id: 'uuid-2',
name: 'model2.safetensors',
- tags: ['models', 'loras'],
- size: 654321
- },
- vae: {
+ tags: ['models', 'loras']
+ }),
+ vae: createTestAsset({
id: 'uuid-3',
name: 'vae1.safetensors',
- tags: ['models', 'vae'],
- size: 789012
- }
+ tags: ['models', 'vae']
+ })
} as const
// Helper functions
@@ -66,24 +80,21 @@ describe('assetService', () => {
describe('getAssetModelFolders', () => {
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
const assets = [
- {
+ createTestAsset({
id: 'uuid-1',
name: 'checkpoint1.safetensors',
- tags: ['models', 'checkpoints'],
- size: 123456
- },
- {
+ tags: ['models', 'checkpoints']
+ }),
+ createTestAsset({
id: 'uuid-2',
name: 'config.yaml',
- tags: ['models', 'configs'], // Blacklisted
- size: 654321
- },
- {
+ tags: ['models', 'configs'] // Blacklisted
+ }),
+ createTestAsset({
id: 'uuid-3',
name: 'vae1.safetensors',
- tags: ['models', 'vae'],
- size: 789012
- }
+ tags: ['models', 'vae']
+ })
]
mockApiResponse(assets)
@@ -123,12 +134,11 @@ describe('assetService', () => {
const assets = [
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
- {
+ createTestAsset({
id: 'uuid-4',
name: 'missing-model.safetensors',
- tags: ['models', 'checkpoints', 'missing'], // Has missing tag
- size: 654321
- }
+ tags: ['models', 'checkpoints', 'missing'] // Has missing tag
+ })
]
mockApiResponse(assets)