mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
feat(assets): add ModelInfoPanel for asset browser right panel (#8090)
## Summary Adds an editable Model Info Panel to show and modify asset details in the asset browser. ## Changes - Add `ModelInfoPanel` component with editable display name, description, model type, base models, and tags - Add `updateAssetMetadata` action in `assetsStore` with optimistic cache updates - Add shadcn-vue `Select` components with design system styling - Add utility functions in `assetMetadataUtils` for extracting model metadata - Convert `BaseModalLayout` right panel state to `defineModel` pattern - Add slide-in animation and collapse button for right panel - Add `class` prop to `PropertiesAccordionItem` for custom styling - Fix keyboard handling: Escape in TagsInput/TextArea doesn't close parent modal ## Testing - Unit tests for `ModelInfoPanel` component - Unit tests for `assetMetadataUtils` functions --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -30,6 +30,10 @@ describe('MyStore', () => {
|
|||||||
|
|
||||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||||
|
|
||||||
|
## i18n in Component Tests
|
||||||
|
|
||||||
|
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
|
||||||
|
|
||||||
## Mock Patterns
|
## Mock Patterns
|
||||||
|
|
||||||
### Reset all mocks at once
|
### Reset all mocks at once
|
||||||
|
|||||||
@@ -5,25 +5,32 @@ import { cn } from '@/utils/tailwindUtil'
|
|||||||
|
|
||||||
import TransitionCollapse from './TransitionCollapse.vue'
|
import TransitionCollapse from './TransitionCollapse.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const {
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
enableEmptyState,
|
||||||
|
tooltip,
|
||||||
|
class: className
|
||||||
|
} = defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
label?: string
|
label?: string
|
||||||
enableEmptyState?: boolean
|
enableEmptyState?: boolean
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
|
class?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isCollapse = defineModel<boolean>('collapse', { default: false })
|
const isCollapse = defineModel<boolean>('collapse', { default: false })
|
||||||
|
|
||||||
const isExpanded = computed(() => !isCollapse.value && !props.disabled)
|
const isExpanded = computed(() => !isCollapse.value && !disabled)
|
||||||
|
|
||||||
const tooltipConfig = computed(() => {
|
const tooltipConfig = computed(() => {
|
||||||
if (!props.tooltip) return undefined
|
if (!tooltip) return undefined
|
||||||
return { value: props.tooltip, showDelay: 1000 }
|
return { value: tooltip, showDelay: 1000 }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col bg-comfy-menu-bg">
|
<div :class="cn('flex flex-col bg-comfy-menu-bg', className)">
|
||||||
<div
|
<div
|
||||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
|
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@
|
|||||||
"source": "Source",
|
"source": "Source",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
|
"use": "Use",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"installed": "Installed",
|
"installed": "Installed",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
@@ -2413,6 +2414,29 @@
|
|||||||
"assetCard": "{name} - {type} asset",
|
"assetCard": "{name} - {type} asset",
|
||||||
"loadingAsset": "Loading 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": {
|
"media": {
|
||||||
"threeDModelPlaceholder": "3D Model",
|
"threeDModelPlaceholder": "3D Model",
|
||||||
"audioPlaceholder": "Audio"
|
"audioPlaceholder": "Audio"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModalLayout
|
<BaseModalLayout
|
||||||
|
v-model:right-panel-open="isRightPanelOpen"
|
||||||
data-component-id="AssetBrowserModal"
|
data-component-id="AssetBrowserModal"
|
||||||
class="size-full max-h-full max-w-full min-w-0"
|
class="size-full max-h-full max-w-full min-w-0"
|
||||||
:content-title="displayTitle"
|
:content-title="displayTitle"
|
||||||
|
:right-panel-title="$t('assetBrowser.modelInfo.title')"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<template v-if="shouldShowLeftPanel" #leftPanel>
|
<template v-if="shouldShowLeftPanel" #leftPanel>
|
||||||
@@ -21,7 +23,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex w-full items-center justify-between gap-2">
|
<div
|
||||||
|
class="flex w-full items-center justify-between gap-2"
|
||||||
|
@click.self="focusedAsset = null"
|
||||||
|
>
|
||||||
<SearchBox
|
<SearchBox
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:autofocus="true"
|
:autofocus="true"
|
||||||
@@ -47,8 +52,8 @@
|
|||||||
<template #contentFilter>
|
<template #contentFilter>
|
||||||
<AssetFilterBar
|
<AssetFilterBar
|
||||||
:assets="categoryFilteredAssets"
|
:assets="categoryFilteredAssets"
|
||||||
:all-assets="fetchedAssets"
|
|
||||||
@filter-change="updateFilters"
|
@filter-change="updateFilters"
|
||||||
|
@click.self="focusedAsset = null"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -56,16 +61,31 @@
|
|||||||
<AssetGrid
|
<AssetGrid
|
||||||
:assets="filteredAssets"
|
:assets="filteredAssets"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
|
:focused-asset-id="focusedAsset?.id"
|
||||||
|
:empty-message
|
||||||
|
@asset-focus="handleAssetFocus"
|
||||||
@asset-select="handleAssetSelectAndEmit"
|
@asset-select="handleAssetSelectAndEmit"
|
||||||
@asset-deleted="refreshAssets"
|
@asset-deleted="refreshAssets"
|
||||||
|
@asset-show-info="handleShowInfo"
|
||||||
|
@click="focusedAsset = null"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #rightPanel>
|
||||||
|
<ModelInfoPanel v-if="focusedAsset" :asset="focusedAsset" :cache-key />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-full items-center justify-center break-words p-6 text-center text-muted"
|
||||||
|
>
|
||||||
|
{{ $t('assetBrowser.modelInfo.selectModelPrompt') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</BaseModalLayout>
|
</BaseModalLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||||
import { computed, provide } from 'vue'
|
import { computed, provide, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchBox from '@/components/common/SearchBox.vue'
|
||||||
@@ -74,8 +94,10 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
|||||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||||
|
import ModelInfoPanel from '@/platform/assets/components/modelInfo/ModelInfoPanel.vue'
|
||||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||||
@@ -132,6 +154,10 @@ async function refreshAssets(): Promise<void> {
|
|||||||
// Trigger background refresh on mount
|
// Trigger background refresh on mount
|
||||||
void refreshAssets()
|
void refreshAssets()
|
||||||
|
|
||||||
|
// Eagerly fetch model types so they're available when ModelInfoPanel loads
|
||||||
|
const { fetchModelTypes } = useModelTypes()
|
||||||
|
void fetchModelTypes()
|
||||||
|
|
||||||
const { isUploadButtonEnabled, showUploadDialog } =
|
const { isUploadButtonEnabled, showUploadDialog } =
|
||||||
useModelUpload(refreshAssets)
|
useModelUpload(refreshAssets)
|
||||||
|
|
||||||
@@ -142,9 +168,13 @@ const {
|
|||||||
navItems,
|
navItems,
|
||||||
categoryFilteredAssets,
|
categoryFilteredAssets,
|
||||||
filteredAssets,
|
filteredAssets,
|
||||||
|
isImportedSelected,
|
||||||
updateFilters
|
updateFilters
|
||||||
} = useAssetBrowser(fetchedAssets)
|
} = useAssetBrowser(fetchedAssets)
|
||||||
|
|
||||||
|
const focusedAsset = ref<AssetDisplayItem | null>(null)
|
||||||
|
const isRightPanelOpen = ref(false)
|
||||||
|
|
||||||
const primaryCategoryTag = computed(() => {
|
const primaryCategoryTag = computed(() => {
|
||||||
const assets = fetchedAssets.value ?? []
|
const assets = fetchedAssets.value ?? []
|
||||||
const tagFromAssets = assets
|
const tagFromAssets = assets
|
||||||
@@ -181,15 +211,30 @@ const shouldShowLeftPanel = computed(() => {
|
|||||||
return props.showLeftPanel ?? true
|
return props.showLeftPanel ?? true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emptyMessage = computed(() => {
|
||||||
|
if (!isImportedSelected.value) return undefined
|
||||||
|
|
||||||
|
return isUploadButtonEnabled.value
|
||||||
|
? t('assetBrowser.emptyImported.canImport')
|
||||||
|
: t('assetBrowser.emptyImported.restricted')
|
||||||
|
})
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAssetFocus(asset: AssetDisplayItem) {
|
||||||
|
focusedAsset.value = asset
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShowInfo(asset: AssetDisplayItem) {
|
||||||
|
focusedAsset.value = asset
|
||||||
|
isRightPanelOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
||||||
emit('asset-select', asset)
|
emit('asset-select', asset)
|
||||||
// onSelect callback is provided by dialog composable layer
|
|
||||||
// It handles the appropriate transformation (filename extraction or full asset)
|
|
||||||
props.onSelect?.(asset)
|
props.onSelect?.(asset)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,30 +9,28 @@
|
|||||||
cn(
|
cn(
|
||||||
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
|
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
|
||||||
interactive &&
|
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)"
|
@keydown.enter.self="interactive && $emit('select', asset)"
|
||||||
>
|
>
|
||||||
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
|
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
|
||||||
<div
|
<div
|
||||||
v-if="isLoading || error"
|
v-if="isLoading || error"
|
||||||
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
|
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
|
||||||
role="button"
|
|
||||||
@click.self="interactive && $emit('select', asset)"
|
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
:src="asset.preview_url"
|
:src="asset.preview_url"
|
||||||
:alt="displayName"
|
:alt="displayName"
|
||||||
class="size-full object-cover cursor-pointer"
|
class="size-full object-cover cursor-pointer"
|
||||||
role="button"
|
|
||||||
@click.self="interactive && $emit('select', asset)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AssetBadgeGroup :badges="asset.badges" />
|
<AssetBadgeGroup :badges="asset.badges" />
|
||||||
<IconGroup
|
<IconGroup
|
||||||
v-if="showAssetOptions"
|
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'absolute top-2 right-2 invisible group-hover:visible',
|
'absolute top-2 right-2 invisible group-hover:visible',
|
||||||
@@ -40,18 +38,21 @@
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<MoreButton ref="dropdown-menu-button" size="sm">
|
<Button
|
||||||
|
v-tooltip.bottom="$t('assetBrowser.modelInfo.title')"
|
||||||
|
:aria-label="$t('assetBrowser.modelInfo.title')"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="$emit('showInfo', asset)"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--info]" />
|
||||||
|
</Button>
|
||||||
|
<MoreButton
|
||||||
|
v-if="showAssetOptions"
|
||||||
|
ref="dropdown-menu-button"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<Button
|
|
||||||
v-if="flags.assetRenameEnabled"
|
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
class="justify-start"
|
|
||||||
@click="startAssetRename"
|
|
||||||
>
|
|
||||||
<i class="icon-[lucide--pencil]" />
|
|
||||||
<span>{{ $t('g.rename') }}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="flags.assetDeletionEnabled"
|
v-if="flags.assetDeletionEnabled"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -72,43 +73,59 @@
|
|||||||
v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
|
v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
|
'm-0 text-sm font-semibold line-clamp-2 wrap-anywhere',
|
||||||
'text-base-foreground'
|
'text-base-foreground'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<EditableText
|
{{ displayName }}
|
||||||
:model-value="displayName"
|
|
||||||
:is-editing="isEditing"
|
|
||||||
:input-attrs="{ 'data-testid': 'asset-name-input' }"
|
|
||||||
@edit="assetRename"
|
|
||||||
@cancel="assetRename()"
|
|
||||||
/>
|
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
:id="descId"
|
:id="descId"
|
||||||
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
|
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
|
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ asset.description }}
|
{{ asset.description }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-4 text-xs text-muted-foreground mt-auto">
|
<div class="flex items-center justify-between gap-2 mt-auto">
|
||||||
<span v-if="asset.stats.stars" class="flex items-center gap-1">
|
<div class="flex gap-3 text-xs text-muted-foreground">
|
||||||
<i class="icon-[lucide--star] size-3" />
|
<span v-if="asset.stats.stars" class="flex items-center gap-1">
|
||||||
{{ asset.stats.stars }}
|
<i class="icon-[lucide--star] size-3" />
|
||||||
</span>
|
{{ asset.stats.stars }}
|
||||||
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
|
</span>
|
||||||
<i class="icon-[lucide--download] size-3" />
|
<span
|
||||||
{{ asset.stats.downloadCount }}
|
v-if="asset.stats.downloadCount"
|
||||||
</span>
|
class="flex items-center gap-1"
|
||||||
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
|
>
|
||||||
<i class="icon-[lucide--clock] size-3" />
|
<i class="icon-[lucide--download] size-3" />
|
||||||
{{ asset.stats.formattedDate }}
|
{{ asset.stats.downloadCount }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="asset.stats.formattedDate"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--clock] size-3" />
|
||||||
|
{{ asset.stats.formattedDate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="interactive"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
class="shrink-0 relative"
|
||||||
|
@click.stop="handleSelect"
|
||||||
|
>
|
||||||
|
{{ $t('g.use') }}
|
||||||
|
<StatusBadge
|
||||||
|
v-if="isNewlyImported"
|
||||||
|
severity="contrast"
|
||||||
|
class="absolute -top-0.5 -right-0.5"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,33 +138,37 @@ import { useI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
import IconGroup from '@/components/button/IconGroup.vue'
|
import IconGroup from '@/components/button/IconGroup.vue'
|
||||||
import MoreButton from '@/components/button/MoreButton.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 { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
|
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { asset, interactive } = defineProps<{
|
const { asset, interactive, focused } = defineProps<{
|
||||||
asset: AssetDisplayItem
|
asset: AssetDisplayItem
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
|
focused?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
focus: [asset: AssetDisplayItem]
|
||||||
select: [asset: AssetDisplayItem]
|
select: [asset: AssetDisplayItem]
|
||||||
deleted: [asset: AssetDisplayItem]
|
deleted: [asset: AssetDisplayItem]
|
||||||
|
showInfo: [asset: AssetDisplayItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { closeDialog } = useDialogStore()
|
const { closeDialog } = useDialogStore()
|
||||||
const { flags } = useFeatureFlags()
|
const { flags } = useFeatureFlags()
|
||||||
const toastStore = useToastStore()
|
const { isDownloadedThisSession, acknowledgeAsset } = useAssetDownloadStore()
|
||||||
|
|
||||||
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
|
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
|
||||||
'dropdown-menu-button'
|
'dropdown-menu-button'
|
||||||
@@ -156,10 +177,9 @@ const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
|
|||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
const descId = useId()
|
const descId = useId()
|
||||||
|
|
||||||
const isEditing = ref(false)
|
const displayName = computed(() => getAssetDisplayName(asset))
|
||||||
const newNameRef = ref<string>()
|
|
||||||
|
|
||||||
const displayName = computed(() => newNameRef.value ?? asset.name)
|
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
|
||||||
|
|
||||||
const showAssetOptions = computed(
|
const showAssetOptions = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -176,6 +196,11 @@ const { isLoading, error } = useImage({
|
|||||||
alt: asset.name
|
alt: asset.name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleSelect() {
|
||||||
|
acknowledgeAsset(asset.id)
|
||||||
|
emit('select', asset)
|
||||||
|
}
|
||||||
|
|
||||||
function confirmDeletion() {
|
function confirmDeletion() {
|
||||||
dropdownMenuButton.value?.hide()
|
dropdownMenuButton.value?.hide()
|
||||||
const assetName = toValue(displayName)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import {
|
|||||||
createAssetWithoutBaseModel
|
createAssetWithoutBaseModel
|
||||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// Mock @/i18n directly since component imports { t } from '@/i18n'
|
const i18n = createI18n({
|
||||||
vi.mock('@/i18n', () => ({
|
legacy: false,
|
||||||
t: (key: string) => key
|
locale: 'en',
|
||||||
}))
|
messages: {
|
||||||
|
en: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Mock components with minimal functionality for business logic testing
|
// Mock components with minimal functionality for business logic testing
|
||||||
vi.mock('@/components/input/MultiSelect.vue', () => ({
|
vi.mock('@/components/input/MultiSelect.vue', () => ({
|
||||||
@@ -66,9 +70,7 @@ function mountAssetFilterBar(props = {}) {
|
|||||||
return mount(AssetFilterBar, {
|
return mount(AssetFilterBar, {
|
||||||
props,
|
props,
|
||||||
global: {
|
global: {
|
||||||
mocks: {
|
plugins: [i18n]
|
||||||
$t: (key: string) => key
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -86,10 +88,6 @@ function findBaseModelsFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
|
|||||||
return wrapper.findComponent('[data-component-id="asset-filter-base-models"]')
|
return wrapper.findComponent('[data-component-id="asset-filter-base-models"]')
|
||||||
}
|
}
|
||||||
|
|
||||||
function findOwnershipFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
|
|
||||||
return wrapper.findComponent('[data-component-id="asset-filter-ownership"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSortFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
|
function findSortFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
|
||||||
return wrapper.findComponent('[data-component-id="asset-filter-sort"]')
|
return wrapper.findComponent('[data-component-id="asset-filter-sort"]')
|
||||||
}
|
}
|
||||||
@@ -268,90 +266,5 @@ describe('AssetFilterBar', () => {
|
|||||||
expect(fileFormatSelect.exists()).toBe(false)
|
expect(fileFormatSelect.exists()).toBe(false)
|
||||||
expect(baseModelSelect.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')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,16 +26,6 @@
|
|||||||
data-component-id="asset-filter-base-models"
|
data-component-id="asset-filter-base-models"
|
||||||
@update:model-value="handleFilterChange"
|
@update:model-value="handleFilterChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SingleSelect
|
|
||||||
v-if="hasMutableAssets"
|
|
||||||
v-model="ownership"
|
|
||||||
:label="$t('assetBrowser.ownership')"
|
|
||||||
:options="ownershipOptions"
|
|
||||||
class="min-w-42"
|
|
||||||
data-component-id="asset-filter-ownership"
|
|
||||||
@update:model-value="handleFilterChange"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center" data-component-id="asset-filter-bar-right">
|
<div class="flex items-center" data-component-id="asset-filter-bar-right">
|
||||||
@@ -57,56 +47,41 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
import type { SelectOption } from '@/components/input/types'
|
import type { SelectOption } from '@/components/input/types'
|
||||||
import { t } from '@/i18n'
|
|
||||||
import type { OwnershipOption } from '@/platform/assets/composables/useAssetBrowser'
|
|
||||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
const SORT_OPTIONS = [
|
const { t } = useI18n()
|
||||||
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
|
|
||||||
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
|
|
||||||
{ name: t('assetBrowser.sortZA'), value: 'name-desc' }
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type SortOption = (typeof SORT_OPTIONS)[number]['value']
|
type SortOption = 'recent' | 'name-asc' | 'name-desc'
|
||||||
|
|
||||||
const sortOptions = [...SORT_OPTIONS]
|
const sortOptions = computed(() => [
|
||||||
|
{ name: t('assetBrowser.sortRecent'), value: 'recent' as const },
|
||||||
const ownershipOptions = [
|
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' as const },
|
||||||
{ name: t('assetBrowser.ownershipAll'), value: 'all' },
|
{ name: t('assetBrowser.sortZA'), value: 'name-desc' as const }
|
||||||
{ name: t('assetBrowser.ownershipMyModels'), value: 'my-models' },
|
])
|
||||||
{ name: t('assetBrowser.ownershipPublicModels'), value: 'public-models' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
fileFormats: string[]
|
fileFormats: string[]
|
||||||
baseModels: string[]
|
baseModels: string[]
|
||||||
sortBy: string
|
sortBy: SortOption
|
||||||
ownership: OwnershipOption
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { assets = [], allAssets = [] } = defineProps<{
|
const { assets = [] } = defineProps<{
|
||||||
assets?: AssetItem[]
|
assets?: AssetItem[]
|
||||||
allAssets?: AssetItem[]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fileFormats = ref<SelectOption[]>([])
|
const fileFormats = ref<SelectOption[]>([])
|
||||||
const baseModels = ref<SelectOption[]>([])
|
const baseModels = ref<SelectOption[]>([])
|
||||||
const sortBy = ref<SortOption>('recent')
|
const sortBy = ref<SortOption>('recent')
|
||||||
const ownership = ref<OwnershipOption>('all')
|
|
||||||
|
|
||||||
const { availableFileFormats, availableBaseModels } =
|
const { availableFileFormats, availableBaseModels } =
|
||||||
useAssetFilterOptions(assets)
|
useAssetFilterOptions(assets)
|
||||||
|
|
||||||
const hasMutableAssets = computed(() => {
|
|
||||||
const assetsToCheck = allAssets.length ? allAssets : assets
|
|
||||||
return assetsToCheck.some((asset) => asset.is_immutable === false)
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
filterChange: [filters: FilterState]
|
filterChange: [filters: FilterState]
|
||||||
}>()
|
}>()
|
||||||
@@ -115,8 +90,7 @@ function handleFilterChange() {
|
|||||||
emit('filterChange', {
|
emit('filterChange', {
|
||||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||||
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||||
sortBy: sortBy.value,
|
sortBy: sortBy.value
|
||||||
ownership: ownership.value
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,9 +19,11 @@
|
|||||||
>
|
>
|
||||||
<i class="mb-4 icon-[lucide--search] size-10" />
|
<i class="mb-4 icon-[lucide--search] size-10" />
|
||||||
<h3 class="mb-2 text-lg font-medium">
|
<h3 class="mb-2 text-lg font-medium">
|
||||||
{{ $t('assetBrowser.noAssetsFound') }}
|
{{ emptyTitle ?? $t('assetBrowser.noAssetsFound') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
|
<p class="text-sm">
|
||||||
|
{{ emptyMessage ?? $t('assetBrowser.tryAdjustingFilters') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<VirtualGrid
|
<VirtualGrid
|
||||||
v-else
|
v-else
|
||||||
@@ -35,8 +37,11 @@
|
|||||||
<AssetCard
|
<AssetCard
|
||||||
:asset="item"
|
:asset="item"
|
||||||
:interactive="true"
|
:interactive="true"
|
||||||
|
:focused="item.id === focusedAssetId"
|
||||||
|
@focus="$emit('assetFocus', $event)"
|
||||||
@select="$emit('assetSelect', $event)"
|
@select="$emit('assetSelect', $event)"
|
||||||
@deleted="$emit('assetDeleted', $event)"
|
@deleted="$emit('assetDeleted', $event)"
|
||||||
|
@show-info="$emit('assetShowInfo', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</VirtualGrid>
|
||||||
@@ -52,14 +57,19 @@ import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
|||||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
|
||||||
const { assets } = defineProps<{
|
const { assets, focusedAssetId, emptyTitle, emptyMessage } = defineProps<{
|
||||||
assets: AssetDisplayItem[]
|
assets: AssetDisplayItem[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
focusedAssetId?: string | null
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyMessage?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
|
assetFocus: [asset: AssetDisplayItem]
|
||||||
assetSelect: [asset: AssetDisplayItem]
|
assetSelect: [asset: AssetDisplayItem]
|
||||||
assetDeleted: [asset: AssetDisplayItem]
|
assetDeleted: [asset: AssetDisplayItem]
|
||||||
|
assetShowInfo: [asset: AssetDisplayItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const assetsWithKey = computed(() =>
|
const assetsWithKey = computed(() =>
|
||||||
@@ -73,7 +83,7 @@ const isLg = breakpoints.greaterOrEqual('lg')
|
|||||||
const isMd = breakpoints.greaterOrEqual('md')
|
const isMd = breakpoints.greaterOrEqual('md')
|
||||||
const maxColumns = computed(() => {
|
const maxColumns = computed(() => {
|
||||||
if (is2Xl.value) return 5
|
if (is2Xl.value) return 5
|
||||||
if (isXl.value) return 4
|
if (isXl.value) return 3
|
||||||
if (isLg.value) return 3
|
if (isLg.value) return 3
|
||||||
if (isMd.value) return 2
|
if (isMd.value) return 2
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
12
src/platform/assets/components/modelInfo/ModelInfoField.vue
Normal file
12
src/platform/assets/components/modelInfo/ModelInfoField.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1 px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
165
src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts
Normal file
165
src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts
Normal file
@@ -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> = {}
|
||||||
|
): 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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
296
src/platform/assets/components/modelInfo/ModelInfoPanel.vue
Normal file
296
src/platform/assets/components/modelInfo/ModelInfoPanel.vue
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-component-id="ModelInfoPanel"
|
||||||
|
class="flex h-full flex-col scrollbar-custom"
|
||||||
|
>
|
||||||
|
<PropertiesAccordionItem :class="accordionClass">
|
||||||
|
<template #label>
|
||||||
|
<span class="text-xs uppercase font-inter">
|
||||||
|
{{ t('assetBrowser.modelInfo.basicInfo') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ModelInfoField :label="t('assetBrowser.modelInfo.displayName')">
|
||||||
|
<EditableText
|
||||||
|
:model-value="displayName"
|
||||||
|
:is-editing="isEditingDisplayName"
|
||||||
|
:class="cn('break-all', !isImmutable && 'text-base-foreground')"
|
||||||
|
@dblclick="isEditingDisplayName = !isImmutable"
|
||||||
|
@edit="handleDisplayNameEdit"
|
||||||
|
@cancel="isEditingDisplayName = false"
|
||||||
|
/>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
|
||||||
|
<span class="break-all">{{ asset.name }}</span>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="sourceUrl"
|
||||||
|
:label="t('assetBrowser.modelInfo.source')"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="sourceUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-1.5 text-muted-foreground no-underline transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="sourceName === 'Civitai'"
|
||||||
|
src="/assets/images/civitai.svg"
|
||||||
|
alt=""
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
{{ t('assetBrowser.modelInfo.viewOnSource', { source: sourceName }) }}
|
||||||
|
<i class="icon-[lucide--external-link] size-4 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</ModelInfoField>
|
||||||
|
</PropertiesAccordionItem>
|
||||||
|
|
||||||
|
<PropertiesAccordionItem :class="accordionClass">
|
||||||
|
<template #label>
|
||||||
|
<span class="text-xs uppercase font-inter">
|
||||||
|
{{ t('assetBrowser.modelInfo.modelTagging') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
|
||||||
|
<Select v-model="selectedModelType" :disabled="isImmutable">
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
<SelectValue
|
||||||
|
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="option in modelTypes"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField :label="t('assetBrowser.modelInfo.compatibleBaseModels')">
|
||||||
|
<TagsInput
|
||||||
|
v-slot="{ isEmpty }"
|
||||||
|
v-model="baseModels"
|
||||||
|
:disabled="isImmutable"
|
||||||
|
>
|
||||||
|
<TagsInputItem
|
||||||
|
v-for="model in baseModels"
|
||||||
|
:key="model"
|
||||||
|
:value="model"
|
||||||
|
>
|
||||||
|
<TagsInputItemText />
|
||||||
|
<TagsInputItemDelete />
|
||||||
|
</TagsInputItem>
|
||||||
|
<TagsInputInput
|
||||||
|
:is-empty="isEmpty"
|
||||||
|
:placeholder="
|
||||||
|
isImmutable
|
||||||
|
? t('assetBrowser.modelInfo.baseModelUnknown')
|
||||||
|
: t('assetBrowser.modelInfo.addBaseModel')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</TagsInput>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField :label="t('assetBrowser.modelInfo.additionalTags')">
|
||||||
|
<TagsInput
|
||||||
|
v-slot="{ isEmpty }"
|
||||||
|
v-model="additionalTags"
|
||||||
|
:disabled="isImmutable"
|
||||||
|
>
|
||||||
|
<TagsInputItem v-for="tag in additionalTags" :key="tag" :value="tag">
|
||||||
|
<TagsInputItemText />
|
||||||
|
<TagsInputItemDelete />
|
||||||
|
</TagsInputItem>
|
||||||
|
<TagsInputInput
|
||||||
|
:is-empty="isEmpty"
|
||||||
|
:placeholder="
|
||||||
|
isImmutable
|
||||||
|
? t('assetBrowser.modelInfo.noAdditionalTags')
|
||||||
|
: t('assetBrowser.modelInfo.addTag')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</TagsInput>
|
||||||
|
</ModelInfoField>
|
||||||
|
</PropertiesAccordionItem>
|
||||||
|
|
||||||
|
<PropertiesAccordionItem :class="accordionClass">
|
||||||
|
<template #label>
|
||||||
|
<span class="text-xs uppercase font-inter">
|
||||||
|
{{ t('assetBrowser.modelInfo.modelDescription') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="triggerPhrases.length > 0"
|
||||||
|
:label="t('assetBrowser.modelInfo.triggerPhrases')"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="phrase in triggerPhrases"
|
||||||
|
:key="phrase"
|
||||||
|
class="rounded px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{{ phrase }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="description"
|
||||||
|
:label="t('assetBrowser.modelInfo.description')"
|
||||||
|
>
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ description }}</p>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField :label="t('assetBrowser.modelInfo.description')">
|
||||||
|
<textarea
|
||||||
|
ref="descriptionTextarea"
|
||||||
|
v-model="userDescription"
|
||||||
|
:disabled="isImmutable"
|
||||||
|
:placeholder="
|
||||||
|
isImmutable
|
||||||
|
? t('assetBrowser.modelInfo.descriptionNotSet')
|
||||||
|
: t('assetBrowser.modelInfo.descriptionPlaceholder')
|
||||||
|
"
|
||||||
|
rows="3"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground outline-none transition-colors focus:bg-component-node-widget-background',
|
||||||
|
isImmutable && 'cursor-not-allowed'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@keydown.escape.stop="descriptionTextarea?.blur()"
|
||||||
|
/>
|
||||||
|
</ModelInfoField>
|
||||||
|
</PropertiesAccordionItem>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import EditableText from '@/components/common/EditableText.vue'
|
||||||
|
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||||
|
import Select from '@/components/ui/select/Select.vue'
|
||||||
|
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||||
|
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||||
|
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||||
|
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||||
|
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
|
||||||
|
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
|
||||||
|
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
|
||||||
|
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
|
||||||
|
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||||
|
import type { AssetUserMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import {
|
||||||
|
getAssetAdditionalTags,
|
||||||
|
getAssetBaseModels,
|
||||||
|
getAssetDescription,
|
||||||
|
getAssetDisplayName,
|
||||||
|
getAssetModelType,
|
||||||
|
getAssetSourceUrl,
|
||||||
|
getAssetTriggerPhrases,
|
||||||
|
getAssetUserDescription,
|
||||||
|
getSourceName
|
||||||
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
import { useAssetsStore } from '@/stores/assetsStore'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import ModelInfoField from './ModelInfoField.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const descriptionTextarea = useTemplateRef<HTMLTextAreaElement>(
|
||||||
|
'descriptionTextarea'
|
||||||
|
)
|
||||||
|
|
||||||
|
const accordionClass = cn(
|
||||||
|
'bg-modal-panel-background border-t border-border-default'
|
||||||
|
)
|
||||||
|
|
||||||
|
const { asset, cacheKey } = defineProps<{
|
||||||
|
asset: AssetDisplayItem
|
||||||
|
cacheKey?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const assetsStore = useAssetsStore()
|
||||||
|
const { modelTypes } = useModelTypes()
|
||||||
|
|
||||||
|
const pendingUpdates = ref<AssetUserMetadata>({})
|
||||||
|
const isEditingDisplayName = ref(false)
|
||||||
|
|
||||||
|
const isImmutable = computed(() => asset.is_immutable ?? true)
|
||||||
|
const displayName = computed(
|
||||||
|
() => pendingUpdates.value.name ?? getAssetDisplayName(asset)
|
||||||
|
)
|
||||||
|
const sourceUrl = computed(() => getAssetSourceUrl(asset))
|
||||||
|
const sourceName = computed(() =>
|
||||||
|
sourceUrl.value ? getSourceName(sourceUrl.value) : ''
|
||||||
|
)
|
||||||
|
const description = computed(() => getAssetDescription(asset))
|
||||||
|
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => asset.user_metadata,
|
||||||
|
() => {
|
||||||
|
pendingUpdates.value = {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const debouncedFlushMetadata = useDebounceFn(() => {
|
||||||
|
if (isImmutable.value) return
|
||||||
|
assetsStore.updateAssetMetadata(
|
||||||
|
asset.id,
|
||||||
|
{ ...(asset.user_metadata ?? {}), ...pendingUpdates.value },
|
||||||
|
cacheKey
|
||||||
|
)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
function queueMetadataUpdate(updates: AssetUserMetadata) {
|
||||||
|
pendingUpdates.value = { ...pendingUpdates.value, ...updates }
|
||||||
|
debouncedFlushMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisplayNameEdit(newName: string) {
|
||||||
|
isEditingDisplayName.value = false
|
||||||
|
if (newName && newName !== displayName.value) {
|
||||||
|
queueMetadataUpdate({ name: newName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
|
||||||
|
if (isImmutable.value) return
|
||||||
|
const currentModelType = getAssetModelType(asset)
|
||||||
|
if (currentModelType === newModelType) return
|
||||||
|
const newTags = asset.tags
|
||||||
|
.filter((tag) => tag !== currentModelType)
|
||||||
|
.concat(newModelType)
|
||||||
|
assetsStore.updateAssetTags(asset.id, newTags, cacheKey)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
const baseModels = computed({
|
||||||
|
get: () => pendingUpdates.value.base_model ?? getAssetBaseModels(asset),
|
||||||
|
set: (value: string[]) => queueMetadataUpdate({ base_model: value })
|
||||||
|
})
|
||||||
|
|
||||||
|
const additionalTags = computed({
|
||||||
|
get: () =>
|
||||||
|
pendingUpdates.value.additional_tags ?? getAssetAdditionalTags(asset),
|
||||||
|
set: (value: string[]) => queueMetadataUpdate({ additional_tags: value })
|
||||||
|
})
|
||||||
|
|
||||||
|
const userDescription = computed({
|
||||||
|
get: () =>
|
||||||
|
pendingUpdates.value.user_description ?? getAssetUserDescription(asset),
|
||||||
|
set: (value: string) => queueMetadataUpdate({ user_description: value })
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedModelType = computed({
|
||||||
|
get: () => getAssetModelType(asset) ?? undefined,
|
||||||
|
set: (value: string | undefined) => {
|
||||||
|
if (value) debouncedSaveModelType(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -295,8 +295,7 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'name-asc',
|
sortBy: 'name-asc',
|
||||||
fileFormats: ['safetensors'],
|
fileFormats: ['safetensors'],
|
||||||
baseModels: [],
|
baseModels: []
|
||||||
ownership: 'all'
|
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -331,8 +330,7 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'name-asc',
|
sortBy: 'name-asc',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: ['SDXL'],
|
baseModels: ['SDXL']
|
||||||
ownership: 'all'
|
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -384,10 +382,9 @@ describe('useAssetBrowser', () => {
|
|||||||
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
|
||||||
|
|
||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'name',
|
sortBy: 'name-asc',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: [],
|
baseModels: []
|
||||||
ownership: 'all'
|
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -411,8 +408,7 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'recent',
|
sortBy: 'recent',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: [],
|
baseModels: []
|
||||||
ownership: 'all'
|
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -444,8 +440,7 @@ describe('useAssetBrowser', () => {
|
|||||||
updateFilters({
|
updateFilters({
|
||||||
sortBy: 'name-asc',
|
sortBy: 'name-asc',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: [],
|
baseModels: []
|
||||||
ownership: 'all'
|
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import { d, t } from '@/i18n'
|
|||||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import {
|
import {
|
||||||
getAssetBaseModel,
|
getAssetBaseModels,
|
||||||
getAssetDescription
|
getAssetDescription,
|
||||||
|
getAssetDisplayName
|
||||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
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 & {})
|
type NavId = 'all' | 'imported' | (string & {})
|
||||||
|
|
||||||
@@ -48,8 +49,8 @@ function filterByBaseModels(models: string[]) {
|
|||||||
return (asset: AssetItem) => {
|
return (asset: AssetItem) => {
|
||||||
if (models.length === 0) return true
|
if (models.length === 0) return true
|
||||||
const modelSet = new Set(models)
|
const modelSet = new Set(models)
|
||||||
const baseModel = getAssetBaseModel(asset)
|
const assetBaseModels = getAssetBaseModels(asset)
|
||||||
return baseModel ? modelSet.has(baseModel) : false
|
return assetBaseModels.some((model) => modelSet.has(model))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +96,7 @@ export function useAssetBrowser(
|
|||||||
const filters = ref<FilterState>({
|
const filters = ref<FilterState>({
|
||||||
sortBy: 'recent',
|
sortBy: 'recent',
|
||||||
fileFormats: [],
|
fileFormats: [],
|
||||||
baseModels: [],
|
baseModels: []
|
||||||
ownership: 'all'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedOwnership = computed<OwnershipOption>(() => {
|
const selectedOwnership = computed<OwnershipOption>(() => {
|
||||||
@@ -135,18 +135,17 @@ export function useAssetBrowser(
|
|||||||
badges.push({ label: badgeLabel, type: 'type' })
|
badges.push({ label: badgeLabel, type: 'type' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base model badge from metadata
|
// Base model badges from metadata
|
||||||
const baseModel = getAssetBaseModel(asset)
|
const baseModels = getAssetBaseModels(asset)
|
||||||
if (baseModel) {
|
for (const model of baseModels) {
|
||||||
badges.push({
|
badges.push({ label: model, type: 'base' })
|
||||||
label: baseModel,
|
|
||||||
type: 'base'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create display stats from API data
|
// Create display stats from API data
|
||||||
const stats = {
|
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
|
downloadCount: undefined, // Not available in API
|
||||||
stars: undefined // Not available in API
|
stars: undefined // Not available in API
|
||||||
}
|
}
|
||||||
@@ -235,7 +234,13 @@ export function useAssetBrowser(
|
|||||||
fuseOptions: {
|
fuseOptions: {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: 'name', weight: 0.4 },
|
{ 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)
|
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
|
ignoreLocation: true, // Search anywhere in the string, not just at the beginning
|
||||||
@@ -264,16 +269,15 @@ export function useAssetBrowser(
|
|||||||
sortedAssets.sort((a, b) => {
|
sortedAssets.sort((a, b) => {
|
||||||
switch (filters.value.sortBy) {
|
switch (filters.value.sortBy) {
|
||||||
case 'name-desc':
|
case 'name-desc':
|
||||||
return b.name.localeCompare(a.name)
|
return getAssetDisplayName(b).localeCompare(getAssetDisplayName(a))
|
||||||
case 'recent':
|
case 'recent':
|
||||||
return (
|
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':
|
case 'name-asc':
|
||||||
default:
|
default:
|
||||||
return a.name.localeCompare(b.name)
|
return getAssetDisplayName(a).localeCompare(getAssetDisplayName(b))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { MaybeRefOrGetter } from 'vue'
|
|||||||
|
|
||||||
import type { SelectOption } from '@/components/input/types'
|
import type { SelectOption } from '@/components/input/types'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable that extracts available filter options from asset data
|
* Composable that extracts available filter options from asset data
|
||||||
@@ -37,12 +38,7 @@ export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
|
|||||||
*/
|
*/
|
||||||
const availableBaseModels = computed<SelectOption[]>(() => {
|
const availableBaseModels = computed<SelectOption[]>(() => {
|
||||||
const assetList = toValue(assets)
|
const assetList = toValue(assets)
|
||||||
const models = assetList
|
const models = assetList.flatMap((asset) => getAssetBaseModels(asset))
|
||||||
.map((asset) => asset.user_metadata?.base_model)
|
|
||||||
.filter(
|
|
||||||
(baseModel): baseModel is string =>
|
|
||||||
baseModel !== undefined && typeof baseModel === 'string'
|
|
||||||
)
|
|
||||||
|
|
||||||
const uniqueModels = uniqWith(models, (a, b) => a === b)
|
const uniqueModels = uniqWith(models, (a, b) => a === b)
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ const DISALLOWED_MODEL_TYPES = ['nlf'] as const
|
|||||||
export const useModelTypes = createSharedComposable(() => {
|
export const useModelTypes = createSharedComposable(() => {
|
||||||
const {
|
const {
|
||||||
state: modelTypes,
|
state: modelTypes,
|
||||||
|
isReady,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
execute: fetchModelTypes
|
execute
|
||||||
} = useAsyncState(
|
} = useAsyncState(
|
||||||
async (): Promise<ModelTypeOption[]> => {
|
async (): Promise<ModelTypeOption[]> => {
|
||||||
const response = await api.getModelFolders()
|
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 {
|
return {
|
||||||
modelTypes,
|
modelTypes,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ const zAsset = z.object({
|
|||||||
tags: z.array(z.string()).optional().default([]),
|
tags: z.array(z.string()).optional().default([]),
|
||||||
preview_id: z.string().nullable().optional(),
|
preview_id: z.string().nullable().optional(),
|
||||||
preview_url: z.string().optional(),
|
preview_url: z.string().optional(),
|
||||||
created_at: z.string(),
|
created_at: z.string().optional(),
|
||||||
updated_at: z.string().optional(),
|
updated_at: z.string().optional(),
|
||||||
is_immutable: z.boolean().optional(),
|
is_immutable: z.boolean().optional(),
|
||||||
last_access_time: z.string().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
|
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,6 +91,21 @@ export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
|
|||||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||||
export type ModelFile = z.infer<typeof zModelFile>
|
export type ModelFile = z.infer<typeof zModelFile>
|
||||||
|
|
||||||
|
/** Payload for updating an asset via PUT /assets/:id */
|
||||||
|
export type AssetUpdatePayload = Partial<
|
||||||
|
Pick<AssetItem, 'name' | 'tags' | 'user_metadata'>
|
||||||
|
>
|
||||||
|
|
||||||
|
/** 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<typeof zAssetUserMetadata>
|
||||||
|
|
||||||
// Legacy interface for backward compatibility (now aligned with Zod schema)
|
// Legacy interface for backward compatibility (now aligned with Zod schema)
|
||||||
export interface ModelFolderInfo {
|
export interface ModelFolderInfo {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
AssetItem,
|
AssetItem,
|
||||||
AssetMetadata,
|
AssetMetadata,
|
||||||
AssetResponse,
|
AssetResponse,
|
||||||
|
AssetUpdatePayload,
|
||||||
AsyncUploadResponse,
|
AsyncUploadResponse,
|
||||||
ModelFile,
|
ModelFile,
|
||||||
ModelFolder
|
ModelFolder
|
||||||
@@ -320,7 +321,7 @@ function createAssetService() {
|
|||||||
*/
|
*/
|
||||||
async function updateAsset(
|
async function updateAsset(
|
||||||
id: string,
|
id: string,
|
||||||
newData: Partial<AssetMetadata>
|
newData: AssetUpdatePayload
|
||||||
): Promise<AssetItem> {
|
): Promise<AssetItem> {
|
||||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
|
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import { describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import {
|
import {
|
||||||
|
getAssetAdditionalTags,
|
||||||
getAssetBaseModel,
|
getAssetBaseModel,
|
||||||
getAssetDescription
|
getAssetBaseModels,
|
||||||
|
getAssetDescription,
|
||||||
|
getAssetDisplayName,
|
||||||
|
getAssetModelType,
|
||||||
|
getAssetSourceUrl,
|
||||||
|
getAssetTriggerPhrases,
|
||||||
|
getAssetUserDescription,
|
||||||
|
getSourceName
|
||||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
|
||||||
describe('assetMetadataUtils', () => {
|
describe('assetMetadataUtils', () => {
|
||||||
@@ -20,20 +28,17 @@ describe('assetMetadataUtils', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('getAssetDescription', () => {
|
describe('getAssetDescription', () => {
|
||||||
it('should return string description when present', () => {
|
it.for([
|
||||||
const asset = {
|
{
|
||||||
...mockAsset,
|
name: 'returns string description when present',
|
||||||
user_metadata: { description: 'A test model' }
|
description: 'A test model',
|
||||||
}
|
expected: 'A test model'
|
||||||
expect(getAssetDescription(asset)).toBe('A test model')
|
},
|
||||||
})
|
{ name: 'returns null for non-string', description: 123, expected: null },
|
||||||
|
{ name: 'returns null for null', description: null, expected: null }
|
||||||
it('should return null when description is not a string', () => {
|
])('$name', ({ description, expected }) => {
|
||||||
const asset = {
|
const asset = { ...mockAsset, user_metadata: { description } }
|
||||||
...mockAsset,
|
expect(getAssetDescription(asset)).toBe(expected)
|
||||||
user_metadata: { description: 123 }
|
|
||||||
}
|
|
||||||
expect(getAssetDescription(asset)).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return null when no metadata', () => {
|
it('should return null when no metadata', () => {
|
||||||
@@ -42,24 +47,228 @@ describe('assetMetadataUtils', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('getAssetBaseModel', () => {
|
describe('getAssetBaseModel', () => {
|
||||||
it('should return string base_model when present', () => {
|
it.for([
|
||||||
const asset = {
|
{
|
||||||
...mockAsset,
|
name: 'returns string base_model when present',
|
||||||
user_metadata: { base_model: 'SDXL' }
|
base_model: 'SDXL',
|
||||||
}
|
expected: 'SDXL'
|
||||||
expect(getAssetBaseModel(asset)).toBe('SDXL')
|
},
|
||||||
})
|
{ name: 'returns null for non-string', base_model: 123, expected: null },
|
||||||
|
{ name: 'returns null for null', base_model: null, expected: null }
|
||||||
it('should return null when base_model is not a string', () => {
|
])('$name', ({ base_model, expected }) => {
|
||||||
const asset = {
|
const asset = { ...mockAsset, user_metadata: { base_model } }
|
||||||
...mockAsset,
|
expect(getAssetBaseModel(asset)).toBe(expected)
|
||||||
user_metadata: { base_model: 123 }
|
|
||||||
}
|
|
||||||
expect(getAssetBaseModel(asset)).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return null when no metadata', () => {
|
it('should return null when no metadata', () => {
|
||||||
expect(getAssetBaseModel(mockAsset)).toBeNull()
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,27 +1,151 @@
|
|||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
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
|
* Safely extracts string description from asset metadata
|
||||||
|
* Checks user_metadata first, then metadata, then returns null
|
||||||
* @param asset - The asset to extract description from
|
* @param asset - The asset to extract description from
|
||||||
* @returns The description string or null if not present/not a string
|
* @returns The description string or null if not present/not a string
|
||||||
*/
|
*/
|
||||||
export function getAssetDescription(asset: AssetItem): string | null {
|
export function getAssetDescription(asset: AssetItem): string | null {
|
||||||
return typeof asset.user_metadata?.description === 'string'
|
return getStringProperty(asset, 'description') ?? null
|
||||||
? asset.user_metadata.description
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely extracts string base_model from asset metadata
|
* 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
|
* @param asset - The asset to extract base_model from
|
||||||
* @returns The base_model string or null if not present/not a string
|
* @returns The base_model string or null if not present/not a string
|
||||||
*/
|
*/
|
||||||
export function getAssetBaseModel(asset: AssetItem): string | null {
|
export function getAssetBaseModel(asset: AssetItem): string | null {
|
||||||
return typeof asset.user_metadata?.base_model === 'string'
|
return getStringProperty(asset, 'base_model') ?? null
|
||||||
? asset.user_metadata.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
|
||||||
|
: ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const timeOptions = {
|
|||||||
second: 'numeric'
|
second: 'numeric'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
function formatTime(time: string) {
|
function formatTime(time?: string) {
|
||||||
if (!time) return ''
|
if (!time) return ''
|
||||||
const date = new Date(time)
|
const date = new Date(time)
|
||||||
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`
|
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ vi.mock('@/stores/assetsStore', () => ({
|
|||||||
getAssets: () => [],
|
getAssets: () => [],
|
||||||
isModelLoading: () => false,
|
isModelLoading: () => false,
|
||||||
getError: () => undefined,
|
getError: () => undefined,
|
||||||
|
hasAssetKey: () => false,
|
||||||
updateModelsForNodeType: mockUpdateModelsForNodeType
|
updateModelsForNodeType: mockUpdateModelsForNodeType
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock('@/platform/distribution/types', () => ({
|
|||||||
const mockAssetsByKey = new Map<string, AssetItem[]>()
|
const mockAssetsByKey = new Map<string, AssetItem[]>()
|
||||||
const mockLoadingByKey = new Map<string, boolean>()
|
const mockLoadingByKey = new Map<string, boolean>()
|
||||||
const mockErrorByKey = new Map<string, Error | undefined>()
|
const mockErrorByKey = new Map<string, Error | undefined>()
|
||||||
|
const mockInitializedKeys = new Set<string>()
|
||||||
const mockUpdateModelsForNodeType = vi.fn()
|
const mockUpdateModelsForNodeType = vi.fn()
|
||||||
const mockGetCategoryForNodeType = vi.fn()
|
const mockGetCategoryForNodeType = vi.fn()
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ vi.mock('@/stores/assetsStore', () => ({
|
|||||||
getAssets: (key: string) => mockAssetsByKey.get(key) ?? [],
|
getAssets: (key: string) => mockAssetsByKey.get(key) ?? [],
|
||||||
isModelLoading: (key: string) => mockLoadingByKey.get(key) ?? false,
|
isModelLoading: (key: string) => mockLoadingByKey.get(key) ?? false,
|
||||||
getError: (key: string) => mockErrorByKey.get(key),
|
getError: (key: string) => mockErrorByKey.get(key),
|
||||||
|
hasAssetKey: (key: string) => mockInitializedKeys.has(key),
|
||||||
updateModelsForNodeType: mockUpdateModelsForNodeType
|
updateModelsForNodeType: mockUpdateModelsForNodeType
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
@@ -35,6 +37,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
mockAssetsByKey.clear()
|
mockAssetsByKey.clear()
|
||||||
mockLoadingByKey.clear()
|
mockLoadingByKey.clear()
|
||||||
mockErrorByKey.clear()
|
mockErrorByKey.clear()
|
||||||
|
mockInitializedKeys.clear()
|
||||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||||
|
|
||||||
mockUpdateModelsForNodeType.mockImplementation(
|
mockUpdateModelsForNodeType.mockImplementation(
|
||||||
@@ -76,6 +79,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
|
|
||||||
mockUpdateModelsForNodeType.mockImplementation(
|
mockUpdateModelsForNodeType.mockImplementation(
|
||||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||||
|
mockInitializedKeys.add(_nodeType)
|
||||||
mockAssetsByKey.set(_nodeType, mockAssets)
|
mockAssetsByKey.set(_nodeType, mockAssets)
|
||||||
mockLoadingByKey.set(_nodeType, false)
|
mockLoadingByKey.set(_nodeType, false)
|
||||||
return mockAssets
|
return mockAssets
|
||||||
@@ -108,6 +112,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
|
|
||||||
mockUpdateModelsForNodeType.mockImplementation(
|
mockUpdateModelsForNodeType.mockImplementation(
|
||||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||||
|
mockInitializedKeys.add(_nodeType)
|
||||||
mockErrorByKey.set(_nodeType, mockError)
|
mockErrorByKey.set(_nodeType, mockError)
|
||||||
mockAssetsByKey.set(_nodeType, [])
|
mockAssetsByKey.set(_nodeType, [])
|
||||||
mockLoadingByKey.set(_nodeType, false)
|
mockLoadingByKey.set(_nodeType, false)
|
||||||
@@ -130,6 +135,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
|
|
||||||
mockUpdateModelsForNodeType.mockImplementation(
|
mockUpdateModelsForNodeType.mockImplementation(
|
||||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||||
|
mockInitializedKeys.add(_nodeType)
|
||||||
mockAssetsByKey.set(_nodeType, [])
|
mockAssetsByKey.set(_nodeType, [])
|
||||||
mockLoadingByKey.set(_nodeType, false)
|
mockLoadingByKey.set(_nodeType, false)
|
||||||
return []
|
return []
|
||||||
@@ -154,6 +160,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||||
mockUpdateModelsForNodeType.mockImplementation(
|
mockUpdateModelsForNodeType.mockImplementation(
|
||||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||||
|
mockInitializedKeys.add(_nodeType)
|
||||||
mockAssetsByKey.set(_nodeType, mockAssets)
|
mockAssetsByKey.set(_nodeType, mockAssets)
|
||||||
mockLoadingByKey.set(_nodeType, false)
|
mockLoadingByKey.set(_nodeType, false)
|
||||||
return mockAssets
|
return mockAssets
|
||||||
@@ -182,6 +189,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
mockGetCategoryForNodeType.mockReturnValue('loras')
|
mockGetCategoryForNodeType.mockReturnValue('loras')
|
||||||
mockUpdateModelsForNodeType.mockImplementation(
|
mockUpdateModelsForNodeType.mockImplementation(
|
||||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||||
|
mockInitializedKeys.add(_nodeType)
|
||||||
mockAssetsByKey.set(_nodeType, mockAssets)
|
mockAssetsByKey.set(_nodeType, mockAssets)
|
||||||
mockLoadingByKey.set(_nodeType, false)
|
mockLoadingByKey.set(_nodeType, false)
|
||||||
return mockAssets
|
return mockAssets
|
||||||
@@ -209,6 +217,7 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
|
|||||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||||
mockUpdateModelsForNodeType.mockImplementation(
|
mockUpdateModelsForNodeType.mockImplementation(
|
||||||
async (_nodeType: string): Promise<AssetItem[]> => {
|
async (_nodeType: string): Promise<AssetItem[]> => {
|
||||||
|
mockInitializedKeys.add(_nodeType)
|
||||||
mockAssetsByKey.set(_nodeType, mockAssets)
|
mockAssetsByKey.set(_nodeType, mockAssets)
|
||||||
mockLoadingByKey.set(_nodeType, false)
|
mockLoadingByKey.set(_nodeType, false)
|
||||||
return mockAssets
|
return mockAssets
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function useAssetWidgetData(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||||
return assets.value.map((asset) => ({
|
return (assets.value ?? []).map((asset) => ({
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
name:
|
name:
|
||||||
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
|
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
|
||||||
@@ -65,10 +65,10 @@ export function useAssetWidgetData(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingAssets = assetsStore.getAssets(currentNodeType) ?? []
|
const isLoading = assetsStore.isModelLoading(currentNodeType)
|
||||||
const hasData = existingAssets.length > 0
|
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
|
||||||
|
|
||||||
if (!hasData) {
|
if (!isLoading && !hasBeenInitialized) {
|
||||||
await assetsStore.updateModelsForNodeType(currentNodeType)
|
await assetsStore.updateModelsForNodeType(currentNodeType)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -319,8 +319,8 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
|
|
||||||
// Verify sorting (newest first - lower index = newer)
|
// Verify sorting (newest first - lower index = newer)
|
||||||
for (let i = 1; i < store.historyAssets.length; i++) {
|
for (let i = 1; i < store.historyAssets.length; i++) {
|
||||||
const prevDate = new Date(store.historyAssets[i - 1].created_at)
|
const prevDate = new Date(store.historyAssets[i - 1].created_at ?? 0)
|
||||||
const currDate = new Date(store.historyAssets[i].created_at)
|
const currDate = new Date(store.historyAssets[i].created_at ?? 0)
|
||||||
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
|
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -435,8 +435,8 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
|
|
||||||
// Should still maintain sorting
|
// Should still maintain sorting
|
||||||
for (let i = 1; i < store.historyAssets.length; i++) {
|
for (let i = 1; i < store.historyAssets.length; i++) {
|
||||||
const prevDate = new Date(store.historyAssets[i - 1].created_at)
|
const prevDate = new Date(store.historyAssets[i - 1].created_at ?? 0)
|
||||||
const currDate = new Date(store.historyAssets[i].created_at)
|
const currDate = new Date(store.historyAssets[i].created_at ?? 0)
|
||||||
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
|
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
|
|||||||
|
|
||||||
return assetItems.sort(
|
return assetItems.sort(
|
||||||
(a, b) =>
|
(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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +146,9 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
loadedIds.add(asset.id)
|
loadedIds.add(asset.id)
|
||||||
|
|
||||||
// Find insertion index to maintain sorted order (newest first)
|
// 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(
|
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) {
|
if (insertIndex === -1) {
|
||||||
@@ -321,6 +322,10 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
return modelStateByKey.value.get(key)?.hasMore ?? false
|
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.
|
* Internal helper to fetch and cache assets with a given key and fetcher.
|
||||||
* Loads first batch immediately, then progressively loads remaining batches.
|
* Loads first batch immediately, then progressively loads remaining batches.
|
||||||
@@ -419,13 +424,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<AssetItem>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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 {
|
return {
|
||||||
getAssets,
|
getAssets,
|
||||||
isLoading,
|
isLoading,
|
||||||
getError,
|
getError,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
hasAssetKey,
|
||||||
updateModelsForNodeType,
|
updateModelsForNodeType,
|
||||||
updateModelsForTag
|
updateModelsForTag,
|
||||||
|
updateAssetMetadata,
|
||||||
|
updateAssetTags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,8 +502,11 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
isLoading: () => false,
|
isLoading: () => false,
|
||||||
getError: () => undefined,
|
getError: () => undefined,
|
||||||
hasMore: () => false,
|
hasMore: () => false,
|
||||||
|
hasAssetKey: () => false,
|
||||||
updateModelsForNodeType: async () => {},
|
updateModelsForNodeType: async () => {},
|
||||||
updateModelsForTag: async () => {}
|
updateModelsForTag: async () => {},
|
||||||
|
updateAssetMetadata: async () => {},
|
||||||
|
updateAssetTags: async () => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,8 +515,11 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
isLoading: isModelLoading,
|
isLoading: isModelLoading,
|
||||||
getError,
|
getError,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
hasAssetKey,
|
||||||
updateModelsForNodeType,
|
updateModelsForNodeType,
|
||||||
updateModelsForTag
|
updateModelsForTag,
|
||||||
|
updateAssetMetadata,
|
||||||
|
updateAssetTags
|
||||||
} = getModelState()
|
} = getModelState()
|
||||||
|
|
||||||
// Watch for completed downloads and refresh model caches
|
// Watch for completed downloads and refresh model caches
|
||||||
@@ -511,9 +584,12 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
isModelLoading,
|
isModelLoading,
|
||||||
getError,
|
getError,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
hasAssetKey,
|
||||||
|
|
||||||
// Model assets - actions
|
// Model assets - actions
|
||||||
updateModelsForNodeType,
|
updateModelsForNodeType,
|
||||||
updateModelsForTag
|
updateModelsForTag,
|
||||||
|
updateAssetMetadata,
|
||||||
|
updateAssetTags
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user