-
-
+
+
+ {{ label }}
+
-
+
+
+
+ {{ $t('g.empty') }}
+
+
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index ea5284eb2..5a4dc9c19 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -183,6 +183,7 @@
"source": "Source",
"filter": "Filter",
"apply": "Apply",
+ "use": "Use",
"enabled": "Enabled",
"installed": "Installed",
"restart": "Restart",
@@ -2388,6 +2389,29 @@
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
},
+ "modelInfo": {
+ "title": "Model Info",
+ "selectModelPrompt": "Select a model to see its information",
+ "basicInfo": "Basic Info",
+ "displayName": "Display Name",
+ "fileName": "File Name",
+ "source": "Source",
+ "viewOnSource": "View on {source}",
+ "modelTagging": "Model Tagging",
+ "modelType": "Model Type",
+ "selectModelType": "Select model type...",
+ "compatibleBaseModels": "Compatible Base Models",
+ "addBaseModel": "Add base model...",
+ "baseModelUnknown": "Base model unknown",
+ "additionalTags": "Additional Tags",
+ "addTag": "Add tag...",
+ "noAdditionalTags": "No additional tags",
+ "modelDescription": "Model Description",
+ "triggerPhrases": "Trigger Phrases",
+ "description": "Description",
+ "descriptionNotSet": "No description set",
+ "descriptionPlaceholder": "Add a description for this model..."
+ },
"media": {
"threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio"
diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue
index c30dd02b6..2b8529333 100644
--- a/src/platform/assets/components/AssetBrowserModal.vue
+++ b/src/platform/assets/components/AssetBrowserModal.vue
@@ -1,8 +1,10 @@
@@ -21,7 +23,10 @@
-
+
@@ -56,16 +61,31 @@
+
+
+
+
+ {{ $t('assetBrowser.modelInfo.selectModelPrompt') }}
+
+
diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue
index 11e404d41..ccaf546c1 100644
--- a/src/platform/assets/components/AssetCard.vue
+++ b/src/platform/assets/components/AssetCard.vue
@@ -9,30 +9,28 @@
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
- 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4'
+ 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4',
+ focused && 'bg-secondary-background outline-solid'
)
"
+ @click.stop="interactive && $emit('focus', asset)"
+ @focus="interactive && $emit('focus', asset)"
@keydown.enter.self="interactive && $emit('select', asset)"
>
-
+
+
+
+
-
-
- {{ $t('g.rename') }}
-
-
+ {{ displayName }}
{{ asset.description }}
-
-
-
- {{ asset.stats.stars }}
-
-
-
- {{ asset.stats.downloadCount }}
-
-
-
- {{ asset.stats.formattedDate }}
-
+
+
+
+
+ {{ asset.stats.stars }}
+
+
+
+ {{ asset.stats.downloadCount }}
+
+
+
+ {{ asset.stats.formattedDate }}
+
+
+
+ {{ $t('g.use') }}
+
+
@@ -121,33 +138,37 @@ import { useI18n } from 'vue-i18n'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
-import EditableText from '@/components/common/EditableText.vue'
+import StatusBadge from '@/components/common/StatusBadge.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
+import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
-import { useToastStore } from '@/platform/updates/common/toastStore'
+import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
-const { asset, interactive } = defineProps<{
+const { asset, interactive, focused } = defineProps<{
asset: AssetDisplayItem
interactive?: boolean
+ focused?: boolean
}>()
const emit = defineEmits<{
+ focus: [asset: AssetDisplayItem]
select: [asset: AssetDisplayItem]
deleted: [asset: AssetDisplayItem]
+ showInfo: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { flags } = useFeatureFlags()
-const toastStore = useToastStore()
+const { isDownloadedThisSession, acknowledgeAsset } = useAssetDownloadStore()
const dropdownMenuButton = useTemplateRef>(
'dropdown-menu-button'
@@ -156,10 +177,9 @@ const dropdownMenuButton = useTemplateRef>(
const titleId = useId()
const descId = useId()
-const isEditing = ref(false)
-const newNameRef = ref()
+const displayName = computed(() => getAssetDisplayName(asset))
-const displayName = computed(() => newNameRef.value ?? asset.name)
+const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
const showAssetOptions = computed(
() =>
@@ -176,6 +196,11 @@ const { isLoading, error } = useImage({
alt: asset.name
})
+function handleSelect() {
+ acknowledgeAsset(asset.id)
+ emit('select', asset)
+}
+
function confirmDeletion() {
dropdownMenuButton.value?.hide()
const assetName = toValue(displayName)
@@ -225,32 +250,4 @@ function confirmDeletion() {
}
})
}
-
-function startAssetRename() {
- dropdownMenuButton.value?.hide()
- isEditing.value = true
-}
-
-async function assetRename(newName?: string) {
- isEditing.value = false
- if (newName) {
- // Optimistic update
- newNameRef.value = newName
- try {
- const result = await assetService.updateAsset(asset.id, {
- name: newName
- })
- // Update with the actual name once the server responds
- newNameRef.value = result.name
- } catch (err: unknown) {
- console.error(err)
- toastStore.add({
- severity: 'error',
- summary: t('assetBrowser.rename.failed'),
- life: 10_000
- })
- newNameRef.value = undefined
- }
- }
-}
diff --git a/src/platform/assets/components/AssetFilterBar.test.ts b/src/platform/assets/components/AssetFilterBar.test.ts
index 49d58b55b..410536231 100644
--- a/src/platform/assets/components/AssetFilterBar.test.ts
+++ b/src/platform/assets/components/AssetFilterBar.test.ts
@@ -10,11 +10,15 @@ import {
createAssetWithoutBaseModel
} from '@/platform/assets/fixtures/ui-mock-assets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { createI18n } from 'vue-i18n'
-// Mock @/i18n directly since component imports { t } from '@/i18n'
-vi.mock('@/i18n', () => ({
- t: (key: string) => key
-}))
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {}
+ }
+})
// Mock components with minimal functionality for business logic testing
vi.mock('@/components/input/MultiSelect.vue', () => ({
@@ -66,9 +70,7 @@ function mountAssetFilterBar(props = {}) {
return mount(AssetFilterBar, {
props,
global: {
- mocks: {
- $t: (key: string) => key
- }
+ plugins: [i18n]
}
})
}
@@ -86,10 +88,6 @@ function findBaseModelsFilter(wrapper: ReturnType) {
return wrapper.findComponent('[data-component-id="asset-filter-base-models"]')
}
-function findOwnershipFilter(wrapper: ReturnType) {
- return wrapper.findComponent('[data-component-id="asset-filter-ownership"]')
-}
-
function findSortFilter(wrapper: ReturnType) {
return wrapper.findComponent('[data-component-id="asset-filter-sort"]')
}
@@ -268,90 +266,5 @@ describe('AssetFilterBar', () => {
expect(fileFormatSelect.exists()).toBe(false)
expect(baseModelSelect.exists()).toBe(false)
})
-
- it('hides ownership filter when no mutable assets', () => {
- const assets = [
- createAssetWithSpecificExtension('safetensors', true) // immutable
- ]
- const wrapper = mountAssetFilterBar({ assets })
-
- const ownershipSelect = findOwnershipFilter(wrapper)
- expect(ownershipSelect.exists()).toBe(false)
- })
-
- it('shows ownership filter when mutable assets exist', () => {
- const assets = [
- createAssetWithSpecificExtension('safetensors', false) // mutable
- ]
- const wrapper = mountAssetFilterBar({ assets })
-
- const ownershipSelect = findOwnershipFilter(wrapper)
- expect(ownershipSelect.exists()).toBe(true)
- })
-
- it('shows ownership filter when mixed assets exist', () => {
- const assets = [
- createAssetWithSpecificExtension('safetensors', true), // immutable
- createAssetWithSpecificExtension('ckpt', false) // mutable
- ]
- const wrapper = mountAssetFilterBar({ assets })
-
- const ownershipSelect = findOwnershipFilter(wrapper)
- expect(ownershipSelect.exists()).toBe(true)
- })
-
- it('shows ownership filter with allAssets when provided', () => {
- const assets = [
- createAssetWithSpecificExtension('safetensors', true) // immutable
- ]
- const allAssets = [
- createAssetWithSpecificExtension('safetensors', true), // immutable
- createAssetWithSpecificExtension('ckpt', false) // mutable
- ]
- const wrapper = mountAssetFilterBar({ assets, allAssets })
-
- const ownershipSelect = findOwnershipFilter(wrapper)
- expect(ownershipSelect.exists()).toBe(true)
- })
- })
-
- describe('Ownership Filter', () => {
- it('emits ownership filter changes', async () => {
- const assets = [
- createAssetWithSpecificExtension('safetensors', false) // mutable
- ]
- const wrapper = mountAssetFilterBar({ assets })
-
- const ownershipSelect = findOwnershipFilter(wrapper)
- expect(ownershipSelect.exists()).toBe(true)
-
- const ownershipSelectElement = ownershipSelect.find('select')
- ownershipSelectElement.element.value = 'my-models'
- await ownershipSelectElement.trigger('change')
- await nextTick()
-
- const emitted = wrapper.emitted('filterChange')
- expect(emitted).toBeTruthy()
-
- const filterState = emitted![emitted!.length - 1][0] as FilterState
- expect(filterState.ownership).toBe('my-models')
- })
-
- it('ownership filter defaults to "all"', async () => {
- const assets = [
- createAssetWithSpecificExtension('safetensors', false) // mutable
- ]
- const wrapper = mountAssetFilterBar({ assets })
-
- const sortSelect = findSortFilter(wrapper)
- const sortSelectElement = sortSelect.find('select')
- sortSelectElement.element.value = 'recent'
- await sortSelectElement.trigger('change')
- await nextTick()
-
- const emitted = wrapper.emitted('filterChange')
- const filterState = emitted![0][0] as FilterState
- expect(filterState.ownership).toBe('all')
- })
})
})
diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue
index 560e3138e..040b1bc87 100644
--- a/src/platform/assets/components/AssetFilterBar.vue
+++ b/src/platform/assets/components/AssetFilterBar.vue
@@ -26,16 +26,6 @@
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
/>
-
-
@@ -57,56 +47,41 @@
diff --git a/src/platform/assets/components/AssetGrid.vue b/src/platform/assets/components/AssetGrid.vue
index 6e91a67fa..05e0b0a10 100644
--- a/src/platform/assets/components/AssetGrid.vue
+++ b/src/platform/assets/components/AssetGrid.vue
@@ -19,9 +19,11 @@
>
- {{ $t('assetBrowser.noAssetsFound') }}
+ {{ emptyTitle ?? $t('assetBrowser.noAssetsFound') }}
-
{{ $t('assetBrowser.tryAdjustingFilters') }}
+
+ {{ emptyMessage ?? $t('assetBrowser.tryAdjustingFilters') }}
+
@@ -52,14 +57,19 @@ import VirtualGrid from '@/components/common/VirtualGrid.vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
-const { assets } = defineProps<{
+const { assets, focusedAssetId, emptyTitle, emptyMessage } = defineProps<{
assets: AssetDisplayItem[]
loading?: boolean
+ focusedAssetId?: string | null
+ emptyTitle?: string
+ emptyMessage?: string
}>()
defineEmits<{
+ assetFocus: [asset: AssetDisplayItem]
assetSelect: [asset: AssetDisplayItem]
assetDeleted: [asset: AssetDisplayItem]
+ assetShowInfo: [asset: AssetDisplayItem]
}>()
const assetsWithKey = computed(() =>
@@ -73,7 +83,7 @@ const isLg = breakpoints.greaterOrEqual('lg')
const isMd = breakpoints.greaterOrEqual('md')
const maxColumns = computed(() => {
if (is2Xl.value) return 5
- if (isXl.value) return 4
+ if (isXl.value) return 3
if (isLg.value) return 3
if (isMd.value) return 2
return 1
diff --git a/src/platform/assets/components/modelInfo/ModelInfoField.vue b/src/platform/assets/components/modelInfo/ModelInfoField.vue
new file mode 100644
index 000000000..34ff0a326
--- /dev/null
+++ b/src/platform/assets/components/modelInfo/ModelInfoField.vue
@@ -0,0 +1,12 @@
+
+
+ {{ label }}
+
+
+
+
+
diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts
new file mode 100644
index 000000000..89300617f
--- /dev/null
+++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts
@@ -0,0 +1,165 @@
+import { mount } from '@vue/test-utils'
+import { createTestingPinia } from '@pinia/testing'
+import { describe, expect, it } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
+
+import ModelInfoPanel from './ModelInfoPanel.vue'
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: {} },
+ missingWarn: false,
+ fallbackWarn: false
+})
+
+describe('ModelInfoPanel', () => {
+ const createMockAsset = (
+ overrides: Partial
= {}
+ ): AssetDisplayItem => ({
+ id: 'test-id',
+ name: 'test-model.safetensors',
+ asset_hash: 'hash123',
+ size: 1024,
+ mime_type: 'application/octet-stream',
+ tags: ['models', 'checkpoints'],
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ last_access_time: '2024-01-01T00:00:00Z',
+ description: 'A test model description',
+ badges: [],
+ stats: {},
+ ...overrides
+ })
+
+ const mountPanel = (asset: AssetDisplayItem) => {
+ return mount(ModelInfoPanel, {
+ props: { asset },
+ global: {
+ plugins: [createTestingPinia({ stubActions: false }), i18n]
+ }
+ })
+ }
+
+ describe('Basic Info Section', () => {
+ it('renders basic info section', () => {
+ const wrapper = mountPanel(createMockAsset())
+ expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo')
+ })
+
+ it('displays asset filename', () => {
+ const asset = createMockAsset({ name: 'my-model.safetensors' })
+ const wrapper = mountPanel(asset)
+ expect(wrapper.text()).toContain('my-model.safetensors')
+ })
+
+ it('displays name from user_metadata when present', () => {
+ const asset = createMockAsset({
+ user_metadata: { name: 'My Custom Model' }
+ })
+ const wrapper = mountPanel(asset)
+ expect(wrapper.text()).toContain('My Custom Model')
+ })
+
+ it('falls back to asset name when user_metadata.name not present', () => {
+ const asset = createMockAsset({ name: 'fallback-model.safetensors' })
+ const wrapper = mountPanel(asset)
+ expect(wrapper.text()).toContain('fallback-model.safetensors')
+ })
+
+ it('renders source link when source_arn is present', () => {
+ const asset = createMockAsset({
+ user_metadata: { source_arn: 'civitai:model:123:version:456' }
+ })
+ const wrapper = mountPanel(asset)
+ const link = wrapper.find(
+ 'a[href="https://civitai.com/models/123?modelVersionId=456"]'
+ )
+ expect(link.exists()).toBe(true)
+ expect(link.attributes('target')).toBe('_blank')
+ })
+
+ it('displays Civitai icon for Civitai source', () => {
+ const asset = createMockAsset({
+ user_metadata: { source_arn: 'civitai:model:123:version:456' }
+ })
+ const wrapper = mountPanel(asset)
+ expect(
+ wrapper.find('img[src="/assets/images/civitai.svg"]').exists()
+ ).toBe(true)
+ })
+
+ it('does not render source field when source_arn is absent', () => {
+ const asset = createMockAsset()
+ const wrapper = mountPanel(asset)
+ const links = wrapper.findAll('a')
+ expect(links).toHaveLength(0)
+ })
+ })
+
+ describe('Model Tagging Section', () => {
+ it('renders model tagging section', () => {
+ const wrapper = mountPanel(createMockAsset())
+ expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging')
+ })
+
+ it('renders model type field', () => {
+ const wrapper = mountPanel(createMockAsset())
+ expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelType')
+ })
+
+ it('renders base models field', () => {
+ const asset = createMockAsset({
+ user_metadata: { base_model: ['SDXL'] }
+ })
+ const wrapper = mountPanel(asset)
+ expect(wrapper.text()).toContain(
+ 'assetBrowser.modelInfo.compatibleBaseModels'
+ )
+ })
+
+ it('renders additional tags field', () => {
+ const wrapper = mountPanel(createMockAsset())
+ expect(wrapper.text()).toContain('assetBrowser.modelInfo.additionalTags')
+ })
+ })
+
+ describe('Model Description Section', () => {
+ it('renders trigger phrases when present', () => {
+ const asset = createMockAsset({
+ user_metadata: { trained_words: ['trigger1', 'trigger2'] }
+ })
+ const wrapper = mountPanel(asset)
+ expect(wrapper.text()).toContain('trigger1')
+ expect(wrapper.text()).toContain('trigger2')
+ })
+
+ it('renders description section', () => {
+ const wrapper = mountPanel(createMockAsset())
+ expect(wrapper.text()).toContain(
+ 'assetBrowser.modelInfo.modelDescription'
+ )
+ })
+
+ it('does not render trigger phrases field when empty', () => {
+ const asset = createMockAsset()
+ const wrapper = mountPanel(asset)
+ expect(wrapper.text()).not.toContain(
+ 'assetBrowser.modelInfo.triggerPhrases'
+ )
+ })
+ })
+
+ describe('Accordion Structure', () => {
+ it('renders all three section labels', () => {
+ const wrapper = mountPanel(createMockAsset())
+ expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo')
+ expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging')
+ expect(wrapper.text()).toContain(
+ 'assetBrowser.modelInfo.modelDescription'
+ )
+ })
+ })
+})
diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
new file mode 100644
index 000000000..c69cb6429
--- /dev/null
+++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
@@ -0,0 +1,296 @@
+
+
+
+
+
diff --git a/src/platform/assets/composables/useAssetBrowser.test.ts b/src/platform/assets/composables/useAssetBrowser.test.ts
index 37df0fc50..f6f4d75c9 100644
--- a/src/platform/assets/composables/useAssetBrowser.test.ts
+++ b/src/platform/assets/composables/useAssetBrowser.test.ts
@@ -295,8 +295,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: ['safetensors'],
- baseModels: [],
- ownership: 'all'
+ baseModels: []
})
await nextTick()
@@ -331,8 +330,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
- baseModels: ['SDXL'],
- ownership: 'all'
+ baseModels: ['SDXL']
})
await nextTick()
@@ -384,10 +382,9 @@ describe('useAssetBrowser', () => {
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({
- sortBy: 'name',
+ sortBy: 'name-asc',
fileFormats: [],
- baseModels: [],
- ownership: 'all'
+ baseModels: []
})
await nextTick()
@@ -411,8 +408,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'recent',
fileFormats: [],
- baseModels: [],
- ownership: 'all'
+ baseModels: []
})
await nextTick()
@@ -444,8 +440,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
- baseModels: [],
- ownership: 'all'
+ baseModels: []
})
await nextTick()
diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts
index 3660fdba2..0b1af9c5d 100644
--- a/src/platform/assets/composables/useAssetBrowser.ts
+++ b/src/platform/assets/composables/useAssetBrowser.ts
@@ -8,13 +8,14 @@ import { d, t } from '@/i18n'
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
- getAssetBaseModel,
- getAssetDescription
+ getAssetBaseModels,
+ getAssetDescription,
+ getAssetDisplayName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
-export type OwnershipOption = 'all' | 'my-models' | 'public-models'
+type OwnershipOption = 'all' | 'my-models' | 'public-models'
type NavId = 'all' | 'imported' | (string & {})
@@ -48,8 +49,8 @@ function filterByBaseModels(models: string[]) {
return (asset: AssetItem) => {
if (models.length === 0) return true
const modelSet = new Set(models)
- const baseModel = getAssetBaseModel(asset)
- return baseModel ? modelSet.has(baseModel) : false
+ const assetBaseModels = getAssetBaseModels(asset)
+ return assetBaseModels.some((model) => modelSet.has(model))
}
}
@@ -95,8 +96,7 @@ export function useAssetBrowser(
const filters = ref({
sortBy: 'recent',
fileFormats: [],
- baseModels: [],
- ownership: 'all'
+ baseModels: []
})
const selectedOwnership = computed(() => {
@@ -135,18 +135,17 @@ export function useAssetBrowser(
badges.push({ label: badgeLabel, type: 'type' })
}
- // Base model badge from metadata
- const baseModel = getAssetBaseModel(asset)
- if (baseModel) {
- badges.push({
- label: baseModel,
- type: 'base'
- })
+ // Base model badges from metadata
+ const baseModels = getAssetBaseModels(asset)
+ for (const model of baseModels) {
+ badges.push({ label: model, type: 'base' })
}
// Create display stats from API data
const stats = {
- formattedDate: d(new Date(asset.created_at), { dateStyle: 'short' }),
+ formattedDate: asset.created_at
+ ? d(new Date(asset.created_at), { dateStyle: 'short' })
+ : undefined,
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
@@ -235,7 +234,13 @@ export function useAssetBrowser(
fuseOptions: {
keys: [
{ name: 'name', weight: 0.4 },
- { name: 'tags', weight: 0.3 }
+ { name: 'tags', weight: 0.3 },
+ { name: 'user_metadata.name', weight: 0.4 },
+ { name: 'user_metadata.additional_tags', weight: 0.3 },
+ { name: 'user_metadata.trained_words', weight: 0.3 },
+ { name: 'user_metadata.user_description', weight: 0.3 },
+ { name: 'metadata.name', weight: 0.4 },
+ { name: 'metadata.trained_words', weight: 0.3 }
],
threshold: 0.4, // Higher threshold for typo tolerance (0.0 = exact, 1.0 = match all)
ignoreLocation: true, // Search anywhere in the string, not just at the beginning
@@ -264,16 +269,15 @@ export function useAssetBrowser(
sortedAssets.sort((a, b) => {
switch (filters.value.sortBy) {
case 'name-desc':
- return b.name.localeCompare(a.name)
+ return getAssetDisplayName(b).localeCompare(getAssetDisplayName(a))
case 'recent':
return (
- new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ new Date(b.created_at ?? 0).getTime() -
+ new Date(a.created_at ?? 0).getTime()
)
- case 'popular':
- return a.name.localeCompare(b.name)
case 'name-asc':
default:
- return a.name.localeCompare(b.name)
+ return getAssetDisplayName(a).localeCompare(getAssetDisplayName(b))
}
})
diff --git a/src/platform/assets/composables/useAssetFilterOptions.ts b/src/platform/assets/composables/useAssetFilterOptions.ts
index a71fadae2..7396c9572 100644
--- a/src/platform/assets/composables/useAssetFilterOptions.ts
+++ b/src/platform/assets/composables/useAssetFilterOptions.ts
@@ -4,6 +4,7 @@ import type { MaybeRefOrGetter } from 'vue'
import type { SelectOption } from '@/components/input/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
/**
* Composable that extracts available filter options from asset data
@@ -37,12 +38,7 @@ export function useAssetFilterOptions(assets: MaybeRefOrGetter) {
*/
const availableBaseModels = computed(() => {
const assetList = toValue(assets)
- const models = assetList
- .map((asset) => asset.user_metadata?.base_model)
- .filter(
- (baseModel): baseModel is string =>
- baseModel !== undefined && typeof baseModel === 'string'
- )
+ const models = assetList.flatMap((asset) => getAssetBaseModels(asset))
const uniqueModels = uniqWith(models, (a, b) => a === b)
diff --git a/src/platform/assets/composables/useModelTypes.ts b/src/platform/assets/composables/useModelTypes.ts
index a60d94813..fecb5fdd4 100644
--- a/src/platform/assets/composables/useModelTypes.ts
+++ b/src/platform/assets/composables/useModelTypes.ts
@@ -46,9 +46,10 @@ const DISALLOWED_MODEL_TYPES = ['nlf'] as const
export const useModelTypes = createSharedComposable(() => {
const {
state: modelTypes,
+ isReady,
isLoading,
error,
- execute: fetchModelTypes
+ execute
} = useAsyncState(
async (): Promise => {
const response = await api.getModelFolders()
@@ -74,6 +75,11 @@ export const useModelTypes = createSharedComposable(() => {
}
)
+ async function fetchModelTypes() {
+ if (isReady.value || isLoading.value) return
+ await execute()
+ }
+
return {
modelTypes,
isLoading,
diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts
index 8a8cf6099..f66d816d4 100644
--- a/src/platform/assets/schemas/assetSchema.ts
+++ b/src/platform/assets/schemas/assetSchema.ts
@@ -10,10 +10,11 @@ const zAsset = z.object({
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
preview_url: z.string().optional(),
- created_at: z.string(),
+ created_at: z.string().optional(),
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
+ metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
@@ -90,6 +91,21 @@ export type AsyncUploadResponse = z.infer
export type ModelFolder = z.infer
export type ModelFile = z.infer
+/** Payload for updating an asset via PUT /assets/:id */
+export type AssetUpdatePayload = Partial<
+ Pick
+>
+
+/** User-editable metadata fields for model assets */
+const zAssetUserMetadata = z.object({
+ name: z.string().optional(),
+ base_model: z.array(z.string()).optional(),
+ additional_tags: z.array(z.string()).optional(),
+ user_description: z.string().optional()
+})
+
+export type AssetUserMetadata = z.infer
+
// Legacy interface for backward compatibility (now aligned with Zod schema)
export interface ModelFolderInfo {
name: string
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts
index fcf0367e8..61b6461ad 100644
--- a/src/platform/assets/services/assetService.ts
+++ b/src/platform/assets/services/assetService.ts
@@ -11,6 +11,7 @@ import type {
AssetItem,
AssetMetadata,
AssetResponse,
+ AssetUpdatePayload,
AsyncUploadResponse,
ModelFile,
ModelFolder
@@ -320,7 +321,7 @@ function createAssetService() {
*/
async function updateAsset(
id: string,
- newData: Partial
+ newData: AssetUpdatePayload
): Promise {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
method: 'PUT',
diff --git a/src/platform/assets/utils/assetMetadataUtils.test.ts b/src/platform/assets/utils/assetMetadataUtils.test.ts
index 54551f595..9154e9f61 100644
--- a/src/platform/assets/utils/assetMetadataUtils.test.ts
+++ b/src/platform/assets/utils/assetMetadataUtils.test.ts
@@ -2,8 +2,16 @@ import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
+ getAssetAdditionalTags,
getAssetBaseModel,
- getAssetDescription
+ getAssetBaseModels,
+ getAssetDescription,
+ getAssetDisplayName,
+ getAssetModelType,
+ getAssetSourceUrl,
+ getAssetTriggerPhrases,
+ getAssetUserDescription,
+ getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
describe('assetMetadataUtils', () => {
@@ -20,20 +28,17 @@ describe('assetMetadataUtils', () => {
}
describe('getAssetDescription', () => {
- it('should return string description when present', () => {
- const asset = {
- ...mockAsset,
- user_metadata: { description: 'A test model' }
- }
- expect(getAssetDescription(asset)).toBe('A test model')
- })
-
- it('should return null when description is not a string', () => {
- const asset = {
- ...mockAsset,
- user_metadata: { description: 123 }
- }
- expect(getAssetDescription(asset)).toBeNull()
+ it.for([
+ {
+ name: 'returns string description when present',
+ description: 'A test model',
+ expected: 'A test model'
+ },
+ { name: 'returns null for non-string', description: 123, expected: null },
+ { name: 'returns null for null', description: null, expected: null }
+ ])('$name', ({ description, expected }) => {
+ const asset = { ...mockAsset, user_metadata: { description } }
+ expect(getAssetDescription(asset)).toBe(expected)
})
it('should return null when no metadata', () => {
@@ -42,24 +47,228 @@ describe('assetMetadataUtils', () => {
})
describe('getAssetBaseModel', () => {
- it('should return string base_model when present', () => {
- const asset = {
- ...mockAsset,
- user_metadata: { base_model: 'SDXL' }
- }
- expect(getAssetBaseModel(asset)).toBe('SDXL')
- })
-
- it('should return null when base_model is not a string', () => {
- const asset = {
- ...mockAsset,
- user_metadata: { base_model: 123 }
- }
- expect(getAssetBaseModel(asset)).toBeNull()
+ it.for([
+ {
+ name: 'returns string base_model when present',
+ base_model: 'SDXL',
+ expected: 'SDXL'
+ },
+ { name: 'returns null for non-string', base_model: 123, expected: null },
+ { name: 'returns null for null', base_model: null, expected: null }
+ ])('$name', ({ base_model, expected }) => {
+ const asset = { ...mockAsset, user_metadata: { base_model } }
+ expect(getAssetBaseModel(asset)).toBe(expected)
})
it('should return null when no metadata', () => {
expect(getAssetBaseModel(mockAsset)).toBeNull()
})
})
+
+ describe('getAssetDisplayName', () => {
+ it.for([
+ {
+ name: 'returns name from user_metadata when present',
+ user_metadata: { name: 'My Custom Name' },
+ expected: 'My Custom Name'
+ },
+ {
+ name: 'falls back to asset name for non-string',
+ user_metadata: { name: 123 },
+ expected: 'test-model'
+ },
+ {
+ name: 'falls back to asset name for undefined',
+ user_metadata: undefined,
+ expected: 'test-model'
+ }
+ ])('$name', ({ user_metadata, expected }) => {
+ const asset = { ...mockAsset, user_metadata }
+ expect(getAssetDisplayName(asset)).toBe(expected)
+ })
+ })
+
+ describe('getAssetSourceUrl', () => {
+ it.for([
+ {
+ name: 'constructs URL from civitai format',
+ source_arn: 'civitai:model:123:version:456',
+ expected: 'https://civitai.com/models/123?modelVersionId=456'
+ },
+ { name: 'returns null for non-string', source_arn: 123, expected: null },
+ {
+ name: 'returns null for unrecognized format',
+ source_arn: 'unknown:format',
+ expected: null
+ }
+ ])('$name', ({ source_arn, expected }) => {
+ const asset = { ...mockAsset, user_metadata: { source_arn } }
+ expect(getAssetSourceUrl(asset)).toBe(expected)
+ })
+
+ it('should return null when no metadata', () => {
+ expect(getAssetSourceUrl(mockAsset)).toBeNull()
+ })
+ })
+
+ describe('getAssetTriggerPhrases', () => {
+ it.for([
+ {
+ name: 'returns array when array present',
+ trained_words: ['phrase1', 'phrase2'],
+ expected: ['phrase1', 'phrase2']
+ },
+ {
+ name: 'wraps single string in array',
+ trained_words: 'single phrase',
+ expected: ['single phrase']
+ },
+ {
+ name: 'filters non-string values from array',
+ trained_words: ['valid', 123, 'also valid', null],
+ expected: ['valid', 'also valid']
+ }
+ ])('$name', ({ trained_words, expected }) => {
+ const asset = { ...mockAsset, user_metadata: { trained_words } }
+ expect(getAssetTriggerPhrases(asset)).toEqual(expected)
+ })
+
+ it('should return empty array when no metadata', () => {
+ expect(getAssetTriggerPhrases(mockAsset)).toEqual([])
+ })
+ })
+
+ describe('getAssetAdditionalTags', () => {
+ it.for([
+ {
+ name: 'returns array of tags when present',
+ additional_tags: ['tag1', 'tag2'],
+ expected: ['tag1', 'tag2']
+ },
+ {
+ name: 'filters non-string values from array',
+ additional_tags: ['valid', 123, 'also valid'],
+ expected: ['valid', 'also valid']
+ },
+ {
+ name: 'returns empty array for non-array',
+ additional_tags: 'not an array',
+ expected: []
+ }
+ ])('$name', ({ additional_tags, expected }) => {
+ const asset = { ...mockAsset, user_metadata: { additional_tags } }
+ expect(getAssetAdditionalTags(asset)).toEqual(expected)
+ })
+
+ it('should return empty array when no metadata', () => {
+ expect(getAssetAdditionalTags(mockAsset)).toEqual([])
+ })
+ })
+
+ describe('getSourceName', () => {
+ it.for([
+ {
+ name: 'returns Civitai for civitai.com',
+ url: 'https://civitai.com/models/123',
+ expected: 'Civitai'
+ },
+ {
+ name: 'returns Hugging Face for huggingface.co',
+ url: 'https://huggingface.co/org/model',
+ expected: 'Hugging Face'
+ },
+ {
+ name: 'returns Source for unknown URLs',
+ url: 'https://example.com/model',
+ expected: 'Source'
+ }
+ ])('$name', ({ url, expected }) => {
+ expect(getSourceName(url)).toBe(expected)
+ })
+ })
+
+ describe('getAssetBaseModels', () => {
+ it.for([
+ {
+ name: 'array of strings',
+ base_model: ['SDXL', 'SD1.5', 'Flux'],
+ expected: ['SDXL', 'SD1.5', 'Flux']
+ },
+ {
+ name: 'filters non-string entries',
+ base_model: ['SDXL', 123, 'SD1.5', null, undefined],
+ expected: ['SDXL', 'SD1.5']
+ },
+ {
+ name: 'single string wrapped in array',
+ base_model: 'SDXL',
+ expected: ['SDXL']
+ },
+ {
+ name: 'non-array/string returns empty',
+ base_model: 123,
+ expected: []
+ },
+ { name: 'undefined returns empty', base_model: undefined, expected: [] }
+ ])('$name', ({ base_model, expected }) => {
+ const asset = { ...mockAsset, user_metadata: { base_model } }
+ expect(getAssetBaseModels(asset)).toEqual(expected)
+ })
+
+ it('should return empty array when no metadata', () => {
+ expect(getAssetBaseModels(mockAsset)).toEqual([])
+ })
+ })
+
+ describe('getAssetModelType', () => {
+ it.for([
+ {
+ name: 'returns model type from tags',
+ tags: ['models', 'checkpoints'],
+ expected: 'checkpoints'
+ },
+ {
+ name: 'extracts last segment from path-style tags',
+ tags: ['models', 'models/loras'],
+ expected: 'loras'
+ },
+ {
+ name: 'returns null when only models tag',
+ tags: ['models'],
+ expected: null
+ },
+ { name: 'returns null when tags empty', tags: [], expected: null }
+ ])('$name', ({ tags, expected }) => {
+ const asset = { ...mockAsset, tags }
+ expect(getAssetModelType(asset)).toBe(expected)
+ })
+ })
+
+ describe('getAssetUserDescription', () => {
+ it.for([
+ {
+ name: 'returns description when present',
+ user_description: 'A custom user description',
+ expected: 'A custom user description'
+ },
+ {
+ name: 'returns empty for non-string',
+ user_description: 123,
+ expected: ''
+ },
+ { name: 'returns empty for null', user_description: null, expected: '' },
+ {
+ name: 'returns empty for undefined',
+ user_description: undefined,
+ expected: ''
+ }
+ ])('$name', ({ user_description, expected }) => {
+ const asset = { ...mockAsset, user_metadata: { user_description } }
+ expect(getAssetUserDescription(asset)).toBe(expected)
+ })
+
+ it('should return empty string when no metadata', () => {
+ expect(getAssetUserDescription(mockAsset)).toBe('')
+ })
+ })
})
diff --git a/src/platform/assets/utils/assetMetadataUtils.ts b/src/platform/assets/utils/assetMetadataUtils.ts
index 2d32fa07f..b2201444d 100644
--- a/src/platform/assets/utils/assetMetadataUtils.ts
+++ b/src/platform/assets/utils/assetMetadataUtils.ts
@@ -1,27 +1,151 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
- * Type-safe utilities for extracting metadata from assets
+ * Type-safe utilities for extracting metadata from assets.
+ * These utilities check user_metadata first, then metadata, then fallback.
*/
+/**
+ * Helper to get a string property from user_metadata or metadata
+ */
+function getStringProperty(asset: AssetItem, key: string): string | undefined {
+ const userValue = asset.user_metadata?.[key]
+ if (typeof userValue === 'string') return userValue
+
+ const metaValue = asset.metadata?.[key]
+ if (typeof metaValue === 'string') return metaValue
+
+ return undefined
+}
+
/**
* Safely extracts string description from asset metadata
+ * Checks user_metadata first, then metadata, then returns null
* @param asset - The asset to extract description from
* @returns The description string or null if not present/not a string
*/
export function getAssetDescription(asset: AssetItem): string | null {
- return typeof asset.user_metadata?.description === 'string'
- ? asset.user_metadata.description
- : null
+ return getStringProperty(asset, 'description') ?? null
}
/**
* Safely extracts string base_model from asset metadata
+ * Checks user_metadata first, then metadata, then returns null
* @param asset - The asset to extract base_model from
* @returns The base_model string or null if not present/not a string
*/
export function getAssetBaseModel(asset: AssetItem): string | null {
- return typeof asset.user_metadata?.base_model === 'string'
- ? asset.user_metadata.base_model
- : null
+ return getStringProperty(asset, 'base_model') ?? null
+}
+
+/**
+ * Extracts base models as an array from asset metadata
+ * Checks user_metadata first, then metadata, then returns empty array
+ * @param asset - The asset to extract base models from
+ * @returns Array of base model strings
+ */
+export function getAssetBaseModels(asset: AssetItem): string[] {
+ const baseModel =
+ asset.user_metadata?.base_model ?? asset.metadata?.base_model
+ if (Array.isArray(baseModel)) {
+ return baseModel.filter((m): m is string => typeof m === 'string')
+ }
+ if (typeof baseModel === 'string' && baseModel) {
+ return [baseModel]
+ }
+ return []
+}
+
+/**
+ * Gets the display name for an asset
+ * Checks user_metadata.name first, then metadata.name, then asset.name
+ * @param asset - The asset to get display name from
+ * @returns The display name
+ */
+export function getAssetDisplayName(asset: AssetItem): string {
+ return getStringProperty(asset, 'name') ?? asset.name
+}
+
+/**
+ * Constructs source URL from asset's source_arn
+ * @param asset - The asset to extract source URL from
+ * @returns The source URL or null if not present/parseable
+ */
+export function getAssetSourceUrl(asset: AssetItem): string | null {
+ // Note: Reversed priority for backwards compatibility
+ const sourceArn =
+ asset.metadata?.source_arn ?? asset.user_metadata?.source_arn
+ if (typeof sourceArn !== 'string') return null
+
+ const civitaiMatch = sourceArn.match(
+ /^civitai:model:(\d+):version:(\d+)(?::file:\d+)?$/
+ )
+ if (civitaiMatch) {
+ const [, modelId, versionId] = civitaiMatch
+ return `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`
+ }
+
+ return null
+}
+
+/**
+ * Extracts trigger phrases from asset metadata
+ * Checks user_metadata first, then metadata, then returns empty array
+ * @param asset - The asset to extract trigger phrases from
+ * @returns Array of trigger phrases
+ */
+export function getAssetTriggerPhrases(asset: AssetItem): string[] {
+ const phrases =
+ asset.user_metadata?.trained_words ?? asset.metadata?.trained_words
+ if (Array.isArray(phrases)) {
+ return phrases.filter((p): p is string => typeof p === 'string')
+ }
+ if (typeof phrases === 'string') return [phrases]
+ return []
+}
+
+/**
+ * Extracts additional tags from asset user_metadata
+ * @param asset - The asset to extract tags from
+ * @returns Array of user-defined tags
+ */
+export function getAssetAdditionalTags(asset: AssetItem): string[] {
+ const tags = asset.user_metadata?.additional_tags
+ if (Array.isArray(tags)) {
+ return tags.filter((t): t is string => typeof t === 'string')
+ }
+ return []
+}
+
+/**
+ * Determines the source name from a URL
+ * @param url - The source URL
+ * @returns Human-readable source name
+ */
+export function getSourceName(url: string): string {
+ if (url.includes('civitai.com')) return 'Civitai'
+ if (url.includes('huggingface.co')) return 'Hugging Face'
+ return 'Source'
+}
+
+/**
+ * Extracts the model type from asset tags
+ * @param asset - The asset to extract model type from
+ * @returns The model type string or null if not present
+ */
+export function getAssetModelType(asset: AssetItem): string | null {
+ const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
+ if (!typeTag) return null
+ return typeTag.includes('/') ? (typeTag.split('/').pop() ?? null) : typeTag
+}
+
+/**
+ * Extracts user description from asset user_metadata
+ * @param asset - The asset to extract user description from
+ * @returns The user description string or empty string if not present
+ */
+export function getAssetUserDescription(asset: AssetItem): string {
+ return typeof asset.user_metadata?.user_description === 'string'
+ ? asset.user_metadata.user_description
+ : ''
}
diff --git a/src/renderer/extensions/linearMode/LinearPreview.vue b/src/renderer/extensions/linearMode/LinearPreview.vue
index 4dacf93a7..82b9eb33b 100644
--- a/src/renderer/extensions/linearMode/LinearPreview.vue
+++ b/src/renderer/extensions/linearMode/LinearPreview.vue
@@ -45,7 +45,7 @@ const timeOptions = {
second: 'numeric'
} as const
-function formatTime(time: string) {
+function formatTime(time?: string) {
if (!time) return ''
const date = new Date(time)
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.desktop.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.desktop.test.ts
index 1c51b9d30..5f7ea30ff 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.desktop.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.desktop.test.ts
@@ -15,6 +15,7 @@ vi.mock('@/stores/assetsStore', () => ({
getAssets: () => [],
isModelLoading: () => false,
getError: () => undefined,
+ hasAssetKey: () => false,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts
index 131d3245d..31362c66b 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts
@@ -11,6 +11,7 @@ vi.mock('@/platform/distribution/types', () => ({
const mockAssetsByKey = new Map()
const mockLoadingByKey = new Map()
const mockErrorByKey = new Map()
+const mockInitializedKeys = new Set()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
@@ -19,6 +20,7 @@ vi.mock('@/stores/assetsStore', () => ({
getAssets: (key: string) => mockAssetsByKey.get(key) ?? [],
isModelLoading: (key: string) => mockLoadingByKey.get(key) ?? false,
getError: (key: string) => mockErrorByKey.get(key),
+ hasAssetKey: (key: string) => mockInitializedKeys.has(key),
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
@@ -35,6 +37,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockAssetsByKey.clear()
mockLoadingByKey.clear()
mockErrorByKey.clear()
+ mockInitializedKeys.clear()
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
@@ -76,6 +79,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise => {
+ mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
@@ -108,6 +112,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise => {
+ mockInitializedKeys.add(_nodeType)
mockErrorByKey.set(_nodeType, mockError)
mockAssetsByKey.set(_nodeType, [])
mockLoadingByKey.set(_nodeType, false)
@@ -130,6 +135,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise => {
+ mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, [])
mockLoadingByKey.set(_nodeType, false)
return []
@@ -154,6 +160,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise => {
+ mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
@@ -182,6 +189,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('loras')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise => {
+ mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
@@ -209,6 +217,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise => {
+ mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts
index 06179edb7..ace04ae6a 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts
@@ -48,7 +48,7 @@ export function useAssetWidgetData(
})
const dropdownItems = computed(() => {
- return assets.value.map((asset) => ({
+ return (assets.value ?? []).map((asset) => ({
id: asset.id,
name:
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
@@ -65,10 +65,10 @@ export function useAssetWidgetData(
return
}
- const existingAssets = assetsStore.getAssets(currentNodeType) ?? []
- const hasData = existingAssets.length > 0
+ const isLoading = assetsStore.isModelLoading(currentNodeType)
+ const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
- if (!hasData) {
+ if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},
diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts
index ed7705f15..b3ebc8f27 100644
--- a/src/stores/assetsStore.test.ts
+++ b/src/stores/assetsStore.test.ts
@@ -374,8 +374,8 @@ describe('assetsStore - Refactored (Option A)', () => {
// Verify sorting (newest first - lower index = newer)
for (let i = 1; i < store.historyAssets.length; i++) {
- const prevDate = new Date(store.historyAssets[i - 1].created_at)
- const currDate = new Date(store.historyAssets[i].created_at)
+ const prevDate = new Date(store.historyAssets[i - 1].created_at ?? 0)
+ const currDate = new Date(store.historyAssets[i].created_at ?? 0)
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
}
})
@@ -500,8 +500,8 @@ describe('assetsStore - Refactored (Option A)', () => {
// Should still maintain sorting
for (let i = 1; i < store.historyAssets.length; i++) {
- const prevDate = new Date(store.historyAssets[i - 1].created_at)
- const currDate = new Date(store.historyAssets[i].created_at)
+ const prevDate = new Date(store.historyAssets[i - 1].created_at ?? 0)
+ const currDate = new Date(store.historyAssets[i].created_at ?? 0)
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
}
})
diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts
index f32a4665f..45378c9a9 100644
--- a/src/stores/assetsStore.ts
+++ b/src/stores/assetsStore.ts
@@ -88,7 +88,8 @@ function mapHistoryToAssets(historyItems: TaskItem[]): AssetItem[] {
return assetItems.sort(
(a, b) =>
- new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ new Date(b.created_at ?? 0).getTime() -
+ new Date(a.created_at ?? 0).getTime()
)
}
@@ -155,9 +156,9 @@ export const useAssetsStore = defineStore('assets', () => {
loadedIds.add(asset.id)
// Find insertion index to maintain sorted order (newest first)
- const assetTime = new Date(asset.created_at).getTime()
+ const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
- (item) => new Date(item.created_at).getTime() < assetTime
+ (item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
@@ -331,6 +332,10 @@ export const useAssetsStore = defineStore('assets', () => {
return modelStateByKey.value.get(key)?.hasMore ?? false
}
+ function hasAssetKey(key: string): boolean {
+ return modelStateByKey.value.has(key)
+ }
+
/**
* Internal helper to fetch and cache assets with a given key and fetcher.
* Loads first batch immediately, then progressively loads remaining batches.
@@ -429,13 +434,75 @@ export const useAssetsStore = defineStore('assets', () => {
)
}
+ /**
+ * Optimistically update an asset in the cache
+ * @param assetId The asset ID to update
+ * @param updates Partial asset data to merge
+ * @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
+ */
+ function updateAssetInCache(
+ assetId: string,
+ updates: Partial,
+ cacheKey?: string
+ ) {
+ const keysToCheck = cacheKey
+ ? [cacheKey]
+ : Array.from(modelStateByKey.value.keys())
+
+ for (const key of keysToCheck) {
+ const state = modelStateByKey.value.get(key)
+ if (!state?.assets) continue
+
+ const existingAsset = state.assets.get(assetId)
+ if (existingAsset) {
+ const updatedAsset = { ...existingAsset, ...updates }
+ state.assets.set(assetId, updatedAsset)
+ assetsArrayCache.delete(key)
+ if (cacheKey) return
+ }
+ }
+ }
+
+ /**
+ * Update asset metadata with optimistic cache update
+ * @param assetId The asset ID to update
+ * @param userMetadata The user_metadata to save
+ * @param cacheKey Optional cache key to target for optimistic update
+ */
+ async function updateAssetMetadata(
+ assetId: string,
+ userMetadata: Record,
+ cacheKey?: string
+ ) {
+ updateAssetInCache(assetId, { user_metadata: userMetadata }, cacheKey)
+ await assetService.updateAsset(assetId, { user_metadata: userMetadata })
+ }
+
+ /**
+ * Update asset tags with optimistic cache update
+ * @param assetId The asset ID to update
+ * @param tags The tags array to save
+ * @param cacheKey Optional cache key to target for optimistic update
+ */
+ async function updateAssetTags(
+ assetId: string,
+ tags: string[],
+ cacheKey?: string
+ ) {
+ updateAssetInCache(assetId, { tags }, cacheKey)
+ await assetService.updateAsset(assetId, { tags })
+ }
+
return {
getAssets,
isLoading,
getError,
hasMore,
+ hasAssetKey,
updateModelsForNodeType,
- updateModelsForTag
+ updateModelsForTag,
+ updateAssetMetadata,
+ updateAssetTags
}
}
@@ -445,8 +512,11 @@ export const useAssetsStore = defineStore('assets', () => {
isLoading: () => false,
getError: () => undefined,
hasMore: () => false,
+ hasAssetKey: () => false,
updateModelsForNodeType: async () => {},
- updateModelsForTag: async () => {}
+ updateModelsForTag: async () => {},
+ updateAssetMetadata: async () => {},
+ updateAssetTags: async () => {}
}
}
@@ -455,8 +525,11 @@ export const useAssetsStore = defineStore('assets', () => {
isLoading: isModelLoading,
getError,
hasMore,
+ hasAssetKey,
updateModelsForNodeType,
- updateModelsForTag
+ updateModelsForTag,
+ updateAssetMetadata,
+ updateAssetTags
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -521,9 +594,12 @@ export const useAssetsStore = defineStore('assets', () => {
isModelLoading,
getError,
hasMore,
+ hasAssetKey,
// Model assets - actions
updateModelsForNodeType,
- updateModelsForTag
+ updateModelsForTag,
+ updateAssetMetadata,
+ updateAssetTags
}
})