mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-28 02:27:21 +00:00
Compare commits
7 Commits
DynamicGro
...
synap5e/fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba433016c | ||
|
|
37807280a4 | ||
|
|
3121149008 | ||
|
|
a59c496778 | ||
|
|
4667b7900e | ||
|
|
82c758fd0d | ||
|
|
dc09506d06 |
@@ -37,7 +37,12 @@
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
:stack-count="getStackCount(item.asset)"
|
||||
:badge-label="
|
||||
item.asset.tags?.includes(TEMP_TAG)
|
||||
? t('mediaAsset.previewBadge')
|
||||
: undefined
|
||||
"
|
||||
:stack-count="item.isChild ? undefined : stackCountFor(item.asset)"
|
||||
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
|
||||
:stack-expanded="isStackExpanded(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@@ -74,6 +79,7 @@ import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { TEMP_TAG } from '@/platform/assets/constants/assetTags'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
@@ -92,13 +98,16 @@ const {
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack
|
||||
toggleStack,
|
||||
getStackCount
|
||||
} = defineProps<{
|
||||
assetItems: OutputStackListItem[]
|
||||
selectableAssets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isStackExpanded: (asset: AssetItem) => boolean
|
||||
toggleStack: (asset: AssetItem) => Promise<void>
|
||||
/** Override the stack count (e.g. job-group size); undefined falls back to history metadata. */
|
||||
getStackCount?: (asset: AssetItem) => number | undefined
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
@@ -158,7 +167,10 @@ function getAssetSecondaryText(asset: AssetItem): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function getStackCount(asset: AssetItem): number | undefined {
|
||||
function stackCountFor(asset: AssetItem): number | undefined {
|
||||
const override = getStackCount?.(asset)
|
||||
if (override !== undefined) return override
|
||||
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.outputCount === 'number') {
|
||||
return metadata.outputCount
|
||||
|
||||
@@ -39,8 +39,15 @@
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
v-model:show-preview-assets="showPreviewAssets"
|
||||
v-model:group-by-job="groupByJob"
|
||||
bottom-divider
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
:show-generation-time-sort="
|
||||
!useAssetApiSource && activeTab === 'output'
|
||||
"
|
||||
:show-alphabetical-sort="useAssetApiSource"
|
||||
:show-asset-toggles="useAssetApiSource && activeTab !== 'input'"
|
||||
:show-sort-options="useAssetApiSource || undefined"
|
||||
/>
|
||||
<!-- Tab list -->
|
||||
<div
|
||||
@@ -48,8 +55,19 @@
|
||||
class="border-b border-comfy-input p-2 2xl:px-4"
|
||||
>
|
||||
<TabList v-model="activeTab">
|
||||
<Tab v-if="useAssetApiSource" value="all">
|
||||
{{ $t('sideToolbar.labels.all') }}
|
||||
</Tab>
|
||||
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
|
||||
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
|
||||
<Tab value="input">
|
||||
{{
|
||||
$t(
|
||||
useAssetApiSource
|
||||
? 'sideToolbar.labels.uploaded'
|
||||
: 'sideToolbar.labels.imported'
|
||||
)
|
||||
}}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
</template>
|
||||
@@ -73,13 +91,7 @@
|
||||
<div v-else-if="showEmptyState">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
$t(
|
||||
activeTab === 'input'
|
||||
? 'sideToolbar.noImportedFiles'
|
||||
: 'sideToolbar.noGeneratedFiles'
|
||||
)
|
||||
"
|
||||
:title="$t(emptyStateTitleKey)"
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
@@ -95,6 +107,7 @@
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:get-stack-count="listViewStackCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@preview-asset="handleZoomClick"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@@ -105,7 +118,7 @@
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
:get-output-count="getDisplayOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@@ -239,9 +252,19 @@ import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBa
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useJobGrouping } from '@/platform/assets/composables/useJobGrouping'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import {
|
||||
INPUT_TAG,
|
||||
OUTPUT_TAG,
|
||||
TEMP_TAG
|
||||
} from '@/platform/assets/constants/assetTags'
|
||||
import type {
|
||||
AssetSortField,
|
||||
AssetSortOrder
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
@@ -250,6 +273,7 @@ import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import {
|
||||
@@ -267,7 +291,7 @@ const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const activeTab = ref<'all' | 'input' | 'output'>('output')
|
||||
const folderJobId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const expectedFolderCount = ref(0)
|
||||
@@ -278,6 +302,21 @@ const viewMode = useStorage<'list' | 'grid'>(
|
||||
)
|
||||
const isListView = computed(() => viewMode.value === 'list')
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
/**
|
||||
* Data-source switch for the sidebar: the assets API path is used when the
|
||||
* setting is on and the API hasn't been detected as unavailable this session
|
||||
* (503/404 on first fetch falls back to the history path).
|
||||
*/
|
||||
const useAssetApiSource = computed(() => assetsStore.assetApiSourceActive)
|
||||
|
||||
const showPreviewAssets = useStorage(
|
||||
'Comfy.Assets.Sidebar.ShowPreviewAssets',
|
||||
false
|
||||
)
|
||||
const groupByJob = useStorage('Comfy.Assets.Sidebar.GroupByJob', false)
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
|
||||
@@ -288,21 +327,29 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
const contextMenuAssetType = computed(() => {
|
||||
if (!contextMenuAsset.value) return 'input'
|
||||
// Preview (temp) assets behave like outputs everywhere except the badge.
|
||||
if (contextMenuAsset.value.tags?.includes(TEMP_TAG)) return 'output'
|
||||
return getAssetType(contextMenuAsset.value.tags)
|
||||
})
|
||||
|
||||
const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
if (isInFolderView.value) return false
|
||||
if (useAssetApiSource.value) {
|
||||
return groupingEnabled.value && getGroupCount(item) > 1
|
||||
}
|
||||
if (activeTab.value !== 'output') return false
|
||||
return getOutputCount(item) > 1
|
||||
}
|
||||
|
||||
const getDisplayOutputCount = (item: AssetItem): number =>
|
||||
groupingEnabled.value ? getGroupCount(item) : getOutputCount(item)
|
||||
|
||||
const formattedExecutionTime = computed(() => {
|
||||
if (!folderExecutionTime.value) return ''
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
@@ -313,6 +360,46 @@ const toast = useToast()
|
||||
const inputAssets = useAssetsApi('input')
|
||||
const outputAssets = useAssetsApi('output')
|
||||
|
||||
/** Tag streams backing each tab on the assets-API path. */
|
||||
const apiTags = computed(() => {
|
||||
const tags: string[] = []
|
||||
if (activeTab.value !== 'input') {
|
||||
tags.push(OUTPUT_TAG)
|
||||
if (showPreviewAssets.value) tags.push(TEMP_TAG)
|
||||
}
|
||||
if (activeTab.value !== 'output') tags.push(INPUT_TAG)
|
||||
return tags
|
||||
})
|
||||
|
||||
const apiSort = computed<{ sort: AssetSortField; order: AssetSortOrder }>(
|
||||
() => {
|
||||
switch (sortBy.value) {
|
||||
case 'oldest':
|
||||
return { sort: 'created_at', order: 'asc' }
|
||||
case 'name-asc':
|
||||
return { sort: 'name', order: 'asc' }
|
||||
case 'name-desc':
|
||||
return { sort: 'name', order: 'desc' }
|
||||
default:
|
||||
return { sort: 'created_at', order: 'desc' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const apiAssetsProvider = {
|
||||
media: computed(() => assetsStore.apiAssets),
|
||||
loading: computed(() => assetsStore.apiLoading),
|
||||
error: computed(() => assetsStore.apiError),
|
||||
fetchMediaList: async () => {
|
||||
await assetsStore.fetchApiAssets({ tags: apiTags.value, ...apiSort.value })
|
||||
return assetsStore.apiAssets
|
||||
},
|
||||
refresh: () => apiAssetsProvider.fetchMediaList(),
|
||||
loadMore: () => assetsStore.loadMoreApiAssets(),
|
||||
hasMore: computed(() => assetsStore.apiHasMore),
|
||||
isLoadingMore: computed(() => assetsStore.apiIsLoadingMore)
|
||||
}
|
||||
|
||||
// Asset selection
|
||||
const {
|
||||
isSelected,
|
||||
@@ -362,12 +449,29 @@ const totalOutputCount = computed(() => {
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
)
|
||||
const currentAssets = computed(() => {
|
||||
if (useAssetApiSource.value) return apiAssetsProvider
|
||||
return activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
})
|
||||
|
||||
const emptyStateTitleKey = computed(() => {
|
||||
if (activeTab.value === 'input') return 'sideToolbar.noImportedFiles'
|
||||
if (activeTab.value === 'all') return 'sideToolbar.noFilesFound'
|
||||
return 'sideToolbar.noGeneratedFiles'
|
||||
})
|
||||
const loading = computed(() => currentAssets.value.loading.value)
|
||||
const error = computed(() => currentAssets.value.error.value)
|
||||
const mediaAssets = computed(() => currentAssets.value.media.value)
|
||||
/**
|
||||
* Loaded assets with preview (temp) assets removed unless the toggle is on.
|
||||
* The streams already fetch temp only when toggled, but this guard keeps
|
||||
* display and group counts correct for assets carrying both output and temp
|
||||
* tags, and for loaded temp assets after the toggle is switched off.
|
||||
*/
|
||||
const mediaAssets = computed(() => {
|
||||
const assets = currentAssets.value.media.value
|
||||
if (!useAssetApiSource.value || showPreviewAssets.value) return assets
|
||||
return assets.filter((asset) => !asset.tags?.includes(TEMP_TAG))
|
||||
})
|
||||
|
||||
const galleryActiveIndex = ref(-1)
|
||||
const currentGalleryAssetId = ref<string | null>(null)
|
||||
@@ -403,8 +507,28 @@ const baseAssets = computed(() => {
|
||||
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
|
||||
useMediaAssetFiltering(baseAssets)
|
||||
|
||||
const isTimeSort = computed(
|
||||
() => sortBy.value === 'newest' || sortBy.value === 'oldest'
|
||||
)
|
||||
|
||||
const groupingEnabled = computed(
|
||||
() =>
|
||||
useAssetApiSource.value &&
|
||||
groupByJob.value &&
|
||||
!isInFolderView.value &&
|
||||
activeTab.value !== 'input'
|
||||
)
|
||||
|
||||
const { groupedAssets, getGroup, getGroupCount } = useJobGrouping({
|
||||
assets: filteredAssets,
|
||||
enabled: groupingEnabled,
|
||||
holdBackTrailing: computed(
|
||||
() => isTimeSort.value && assetsStore.apiHasMore && !searchQuery.value
|
||||
)
|
||||
})
|
||||
|
||||
const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
return groupingEnabled.value ? groupedAssets.value : filteredAssets.value
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -413,9 +537,22 @@ const {
|
||||
isStackExpanded: isListViewStackExpanded,
|
||||
toggleStack: toggleListViewStack
|
||||
} = useOutputStacks({
|
||||
assets: computed(() => displayAssets.value)
|
||||
assets: computed(() => displayAssets.value),
|
||||
// With job grouping on, stacks expand the in-memory group instead of
|
||||
// resolving history outputs.
|
||||
getJobId: (asset) =>
|
||||
groupingEnabled.value ? (getGroup(asset)?.jobId ?? null) : undefined,
|
||||
liveChildren: (asset) => {
|
||||
if (!groupingEnabled.value) return undefined
|
||||
const group = getGroup(asset)
|
||||
if (!group) return []
|
||||
return group.assets.filter((member) => member.id !== asset.id)
|
||||
}
|
||||
})
|
||||
|
||||
const listViewStackCount = (asset: AssetItem): number | undefined =>
|
||||
groupingEnabled.value ? getGroupCount(asset) : undefined
|
||||
|
||||
const visibleAssets = computed(() => {
|
||||
if (!isListView.value) return displayAssets.value
|
||||
return listViewSelectableAssets.value
|
||||
@@ -495,8 +632,22 @@ const refreshAssets = async () => {
|
||||
}
|
||||
|
||||
watch(
|
||||
activeTab,
|
||||
[activeTab, useAssetApiSource],
|
||||
() => {
|
||||
if (!useAssetApiSource.value) {
|
||||
// Leaving the API path (setting off or runtime fallback): drop
|
||||
// API-only state so the history path behaves exactly as before.
|
||||
if (activeTab.value === 'all') {
|
||||
activeTab.value = 'output'
|
||||
return
|
||||
}
|
||||
if (sortBy.value === 'name-asc' || sortBy.value === 'name-desc') {
|
||||
sortBy.value = 'newest'
|
||||
}
|
||||
} else if (sortBy.value === 'longest' || sortBy.value === 'fastest') {
|
||||
// Generation-time sorts don't exist on asset records.
|
||||
sortBy.value = 'newest'
|
||||
}
|
||||
clearSelection()
|
||||
// Clear search when switching tabs
|
||||
searchQuery.value = ''
|
||||
@@ -506,6 +657,15 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Server-side parameters of the API path: refetch when they change, even
|
||||
// while in folder view, so the main list is fresh on exit. (On the history
|
||||
// path sorting stays client-side, as before.)
|
||||
watch([showPreviewAssets, apiSort], () => {
|
||||
if (useAssetApiSource.value) {
|
||||
void refreshAssets()
|
||||
}
|
||||
})
|
||||
|
||||
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
const assetList = assets ?? visibleAssets.value
|
||||
const index = assetList.findIndex((a) => a.id === asset.id)
|
||||
@@ -603,6 +763,16 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
const enterFolderView = async (asset: AssetItem) => {
|
||||
if (groupingEnabled.value) {
|
||||
const group = getGroup(asset)
|
||||
if (!group?.jobId) return
|
||||
folderJobId.value = group.jobId
|
||||
folderExecutionTime.value = undefined
|
||||
expectedFolderCount.value = group.assets.length
|
||||
folderAssets.value = group.assets
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!metadata) {
|
||||
console.warn('Invalid output asset metadata')
|
||||
@@ -679,9 +849,18 @@ const copyJobId = async () => {
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (isInFolderView.value) return
|
||||
if (useAssetApiSource.value) {
|
||||
if (
|
||||
apiAssetsProvider.hasMore.value &&
|
||||
!apiAssetsProvider.isLoadingMore.value
|
||||
) {
|
||||
await apiAssetsProvider.loadMore()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
activeTab.value === 'output' &&
|
||||
!isInFolderView.value &&
|
||||
outputAssets.hasMore.value &&
|
||||
!outputAssets.isLoadingMore.value
|
||||
) {
|
||||
|
||||
@@ -854,7 +854,11 @@
|
||||
"filterAudio": "Audio",
|
||||
"filter3D": "3D",
|
||||
"filterText": "Text",
|
||||
"viewSettings": "View settings"
|
||||
"viewSettings": "View settings",
|
||||
"sortNameAsc": "Alphabetical (A→Z)",
|
||||
"sortNameDesc": "Alphabetical (Z→A)",
|
||||
"showPreviewAssets": "Show preview assets",
|
||||
"groupByJob": "Group assets by job"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
"folderView": {
|
||||
@@ -872,7 +876,9 @@
|
||||
"console": "Console",
|
||||
"menu": "Menu",
|
||||
"imported": "Imported",
|
||||
"generated": "Generated"
|
||||
"generated": "Generated",
|
||||
"all": "All",
|
||||
"uploaded": "Uploaded"
|
||||
},
|
||||
"noFilesFound": "No files found",
|
||||
"noImportedFiles": "No imported files found",
|
||||
@@ -3210,7 +3216,8 @@
|
||||
"openWorkflow": "Open as workflow in new tab",
|
||||
"exportWorkflow": "Export workflow",
|
||||
"copyJobId": "Copy job ID",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"keepPreview": "Keep preview"
|
||||
},
|
||||
"jobIdToast": {
|
||||
"jobIdCopied": "Job ID copied to clipboard",
|
||||
@@ -3253,7 +3260,11 @@
|
||||
"noWorkflowDataFound": "No workflow data found in this asset",
|
||||
"workflowOpenedInNewTab": "Workflow opened in new tab",
|
||||
"failedToExportWorkflow": "Failed to export workflow",
|
||||
"workflowExportedSuccessfully": "Workflow exported successfully"
|
||||
"workflowExportedSuccessfully": "Workflow exported successfully",
|
||||
"previewBadge": "PREV.",
|
||||
"previewBadgeTooltip": "Temporary outputs from workflow runs. Right click to save this preview.",
|
||||
"previewKept": "Preview saved to Media Assets",
|
||||
"failedToKeepPreview": "Failed to keep preview"
|
||||
},
|
||||
"actionbar": {
|
||||
"dockToTop": "Dock to top",
|
||||
|
||||
@@ -97,6 +97,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="badgeLabel"
|
||||
class="relative z-1 flex h-3.5 shrink-0 items-center rounded-full bg-base-foreground px-1 text-[9px] font-semibold text-base-background uppercase"
|
||||
>
|
||||
{{ badgeLabel }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="relative z-1 flex shrink-0 items-center gap-2"
|
||||
@@ -156,6 +163,7 @@ const {
|
||||
isVideoPreview = false,
|
||||
primaryText,
|
||||
secondaryText,
|
||||
badgeLabel,
|
||||
stackCount,
|
||||
stackIndicatorLabel,
|
||||
stackExpanded = false,
|
||||
@@ -171,6 +179,7 @@ const {
|
||||
isVideoPreview?: boolean
|
||||
primaryText?: string
|
||||
secondaryText?: string
|
||||
badgeLabel?: string
|
||||
stackCount?: number
|
||||
stackIndicatorLabel?: string
|
||||
stackExpanded?: boolean
|
||||
|
||||
@@ -116,9 +116,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Output count -->
|
||||
<div v-if="showOutputCount" class="shrink-0">
|
||||
<!-- Right side: Preview badge and output count -->
|
||||
<div
|
||||
v-if="isTempAsset || showOutputCount"
|
||||
class="flex shrink-0 items-center gap-1"
|
||||
>
|
||||
<span
|
||||
v-if="isTempAsset"
|
||||
v-tooltip.top="$t('mediaAsset.previewBadgeTooltip')"
|
||||
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-[9px] font-semibold text-base-background uppercase"
|
||||
>
|
||||
{{ $t('mediaAsset.previewBadge') }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="showOutputCount"
|
||||
v-tooltip.top.pt:pointer-events-none="
|
||||
$t('mediaAsset.actions.seeMoreOutputs')
|
||||
"
|
||||
@@ -154,6 +165,7 @@ import {
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
|
||||
import { TEMP_TAG } from '../constants/assetTags'
|
||||
import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
@@ -219,6 +231,8 @@ const assetType = computed(() => {
|
||||
return getAssetType(asset?.tags)
|
||||
})
|
||||
|
||||
const isTempAsset = computed(() => asset?.tags?.includes(TEMP_TAG) ?? false)
|
||||
|
||||
// Determine file type from extension
|
||||
const fileKind = computed((): MediaKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
|
||||
@@ -43,6 +43,7 @@ import { isPreviewableMediaType } from '@/utils/formatUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { TEMP_TAG } from '../constants/assetTags'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
|
||||
@@ -213,6 +214,15 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
// Keep preview (temp assets only): promote to permanent output asset
|
||||
if (asset.tags?.includes(TEMP_TAG)) {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.keepPreview'),
|
||||
icon: 'icon-[lucide--bookmark]',
|
||||
command: () => actions.keepPreview(asset)
|
||||
})
|
||||
}
|
||||
|
||||
// Download
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.download'),
|
||||
|
||||
@@ -27,8 +27,12 @@
|
||||
<MediaAssetSettingsMenu
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:sort-by="sortBy"
|
||||
:show-sort-options="isCloud"
|
||||
v-model:show-preview-assets="showPreviewAssets"
|
||||
v-model:group-by-job="groupByJob"
|
||||
:show-sort-options="showSortOptions ?? isCloud"
|
||||
:show-generation-time-sort
|
||||
:show-alphabetical-sort="showAlphabeticalSort"
|
||||
:show-asset-toggles="showAssetToggles"
|
||||
/>
|
||||
</template>
|
||||
</MediaAssetSettingsButton>
|
||||
@@ -47,9 +51,18 @@ import MediaAssetSettingsButton from './MediaAssetSettingsButton.vue'
|
||||
import MediaAssetSettingsMenu from './MediaAssetSettingsMenu.vue'
|
||||
import type { SortBy } from './MediaAssetSettingsMenu.vue'
|
||||
|
||||
const { showGenerationTimeSort = false, bottomDivider = false } = defineProps<{
|
||||
const {
|
||||
showGenerationTimeSort = false,
|
||||
showAlphabeticalSort = false,
|
||||
showAssetToggles = false,
|
||||
showSortOptions = undefined,
|
||||
bottomDivider = false
|
||||
} = defineProps<{
|
||||
searchQuery: string
|
||||
showGenerationTimeSort?: boolean
|
||||
showAlphabeticalSort?: boolean
|
||||
showAssetToggles?: boolean
|
||||
showSortOptions?: boolean
|
||||
mediaTypeFilters: string[]
|
||||
bottomDivider?: boolean
|
||||
}>()
|
||||
@@ -61,6 +74,10 @@ const emit = defineEmits<{
|
||||
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
const showPreviewAssets = defineModel<boolean>('showPreviewAssets', {
|
||||
default: false
|
||||
})
|
||||
const groupByJob = defineModel<boolean>('groupByJob', { default: false })
|
||||
|
||||
const handleSearchChange = (value: string | undefined) => {
|
||||
emit('update:searchQuery', value ?? '')
|
||||
|
||||
@@ -57,6 +57,32 @@
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<template v-if="showAlphabeticalSort">
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
@click="handleSortChange('name-asc')"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.sortNameAsc') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="sortBy !== 'name-asc' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
@click="handleSortChange('name-desc')"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.sortNameDesc') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="sortBy !== 'name-desc' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-if="showGenerationTimeSort">
|
||||
<Button
|
||||
variant="textonly"
|
||||
@@ -83,22 +109,70 @@
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="showAssetToggles">
|
||||
<div class="my-1 w-full border-b border-border-subtle" />
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
role="menuitemcheckbox"
|
||||
:aria-checked="groupByJob"
|
||||
@click="groupByJob = !groupByJob"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.groupByJob') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="!groupByJob && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
role="menuitemcheckbox"
|
||||
:aria-checked="showPreviewAssets"
|
||||
@click="showPreviewAssets = !showPreviewAssets"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.showPreviewAssets') }}</span>
|
||||
<i
|
||||
class="ml-auto icon-[lucide--check] size-4"
|
||||
:class="!showPreviewAssets && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
export type SortBy = 'newest' | 'oldest' | 'longest' | 'fastest'
|
||||
export type SortBy =
|
||||
| 'newest'
|
||||
| 'oldest'
|
||||
| 'longest'
|
||||
| 'fastest'
|
||||
| 'name-asc'
|
||||
| 'name-desc'
|
||||
|
||||
const { showSortOptions = false, showGenerationTimeSort = false } =
|
||||
defineProps<{
|
||||
showSortOptions?: boolean
|
||||
showGenerationTimeSort?: boolean
|
||||
}>()
|
||||
const {
|
||||
showSortOptions = false,
|
||||
showGenerationTimeSort = false,
|
||||
showAlphabeticalSort = false,
|
||||
showAssetToggles = false
|
||||
} = defineProps<{
|
||||
showSortOptions?: boolean
|
||||
showGenerationTimeSort?: boolean
|
||||
showAlphabeticalSort?: boolean
|
||||
showAssetToggles?: boolean
|
||||
}>()
|
||||
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
const showPreviewAssets = defineModel<boolean>('showPreviewAssets', {
|
||||
default: false
|
||||
})
|
||||
const groupByJob = defineModel<boolean>('groupByJob', { default: false })
|
||||
|
||||
function handleViewModeChange(value: 'list' | 'grid') {
|
||||
viewMode.value = value
|
||||
|
||||
98
src/platform/assets/composables/useJobGrouping.test.ts
Normal file
98
src/platform/assets/composables/useJobGrouping.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useJobGrouping } from '@/platform/assets/composables/useJobGrouping'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
function asset(id: string, jobId?: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
tags: ['output'],
|
||||
job_id: jobId
|
||||
}
|
||||
}
|
||||
|
||||
function setup(
|
||||
items: AssetItem[],
|
||||
{ enabled = true, holdBackTrailing = false } = {}
|
||||
) {
|
||||
const assets = ref(items)
|
||||
const grouping = useJobGrouping({
|
||||
assets,
|
||||
enabled: ref(enabled),
|
||||
holdBackTrailing: ref(holdBackTrailing)
|
||||
})
|
||||
return { assets, ...grouping }
|
||||
}
|
||||
|
||||
describe('useJobGrouping', () => {
|
||||
it('passes assets through unchanged when disabled', () => {
|
||||
const items = [asset('a', 'job1'), asset('b', 'job1')]
|
||||
const { groupedAssets } = setup(items, { enabled: false })
|
||||
expect(groupedAssets.value).toEqual(items)
|
||||
})
|
||||
|
||||
it('buckets assets by job_id with first occurrence as representative', () => {
|
||||
const { groupedAssets, getGroupCount } = setup([
|
||||
asset('a1', 'job1'),
|
||||
asset('b1', 'job2'),
|
||||
asset('a2', 'job1'),
|
||||
asset('a3', 'job1')
|
||||
])
|
||||
|
||||
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a1', 'b1'])
|
||||
expect(getGroupCount(asset('a1', 'job1'))).toBe(3)
|
||||
expect(getGroupCount(asset('b1', 'job2'))).toBe(1)
|
||||
})
|
||||
|
||||
it('treats assets without job_id as singleton groups', () => {
|
||||
const { groupedAssets, getGroupCount } = setup([
|
||||
asset('a', 'job1'),
|
||||
asset('x'),
|
||||
asset('y')
|
||||
])
|
||||
|
||||
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a', 'x', 'y'])
|
||||
expect(getGroupCount(asset('x'))).toBe(1)
|
||||
})
|
||||
|
||||
it('exposes group members for drill-in', () => {
|
||||
const { getGroup } = setup([
|
||||
asset('a1', 'job1'),
|
||||
asset('b1', 'job2'),
|
||||
asset('a2', 'job1')
|
||||
])
|
||||
|
||||
const group = getGroup(asset('a2', 'job1'))
|
||||
expect(group?.jobId).toBe('job1')
|
||||
expect(group?.assets.map((a) => a.id)).toEqual(['a1', 'a2'])
|
||||
})
|
||||
|
||||
it('holds back the group containing the trailing asset', () => {
|
||||
const { groupedAssets } = setup(
|
||||
[asset('a1', 'job1'), asset('b1', 'job2'), asset('b2', 'job2')],
|
||||
{ holdBackTrailing: true }
|
||||
)
|
||||
|
||||
// job2 owns the last loaded asset; the next page may add more members.
|
||||
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a1'])
|
||||
})
|
||||
|
||||
it('keeps the trailing group when it is the only group', () => {
|
||||
const { groupedAssets } = setup(
|
||||
[asset('a1', 'job1'), asset('a2', 'job1')],
|
||||
{ holdBackTrailing: true }
|
||||
)
|
||||
|
||||
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a1'])
|
||||
})
|
||||
|
||||
it('reacts to asset list changes', () => {
|
||||
const { assets, groupedAssets } = setup([asset('a1', 'job1')])
|
||||
expect(groupedAssets.value).toHaveLength(1)
|
||||
|
||||
assets.value = [...assets.value, asset('a2', 'job1'), asset('c1', 'job3')]
|
||||
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a1', 'c1'])
|
||||
})
|
||||
})
|
||||
90
src/platform/assets/composables/useJobGrouping.ts
Normal file
90
src/platform/assets/composables/useJobGrouping.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
export interface JobGroup {
|
||||
/** Stable key: the job id, or a per-asset key for assets without one. */
|
||||
key: string
|
||||
jobId: string | null
|
||||
/** First group member in display order; shown as the group's card. */
|
||||
representative: AssetItem
|
||||
assets: AssetItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side group-by-job over the loaded (already filtered and sorted)
|
||||
* asset list. Assets without a job_id form singleton groups. Group order
|
||||
* and each group's representative follow first occurrence in display order,
|
||||
* so grouping works under any active sort.
|
||||
*
|
||||
* While more pages exist under a time sort, the group containing the last
|
||||
* loaded asset may still be split across the page boundary; `holdBackTrailing`
|
||||
* hides it until the next page lands (or streams exhaust) to avoid
|
||||
* undercounting.
|
||||
*/
|
||||
export function useJobGrouping(options: {
|
||||
assets: Ref<AssetItem[]>
|
||||
enabled: Ref<boolean>
|
||||
holdBackTrailing: Ref<boolean>
|
||||
}) {
|
||||
const { assets, enabled, holdBackTrailing } = options
|
||||
|
||||
const groups = computed<JobGroup[]>(() => {
|
||||
if (!enabled.value) return []
|
||||
|
||||
const byKey = new Map<string, JobGroup>()
|
||||
for (const asset of assets.value) {
|
||||
const key = asset.job_id ?? `asset:${asset.id}`
|
||||
const group = byKey.get(key)
|
||||
if (group) {
|
||||
group.assets.push(asset)
|
||||
} else {
|
||||
byKey.set(key, {
|
||||
key,
|
||||
jobId: asset.job_id ?? null,
|
||||
representative: asset,
|
||||
assets: [asset]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = [...byKey.values()]
|
||||
if (holdBackTrailing.value && result.length > 1) {
|
||||
const lastAsset = assets.value[assets.value.length - 1]
|
||||
const trailingKey = lastAsset.job_id ?? `asset:${lastAsset.id}`
|
||||
return result.filter((group) => group.key !== trailingKey)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const groupsByAssetId = computed(() => {
|
||||
const map = new Map<string, JobGroup>()
|
||||
for (const group of groups.value) {
|
||||
for (const asset of group.assets) {
|
||||
map.set(asset.id, group)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** Representatives in display order; the grid renders one card per group. */
|
||||
const groupedAssets = computed<AssetItem[]>(() => {
|
||||
if (!enabled.value) return assets.value
|
||||
return groups.value.map((group) => group.representative)
|
||||
})
|
||||
|
||||
function getGroup(asset: AssetItem): JobGroup | undefined {
|
||||
return groupsByAssetId.value.get(asset.id)
|
||||
}
|
||||
|
||||
function getGroupCount(asset: AssetItem): number {
|
||||
return getGroup(asset)?.assets.length ?? 1
|
||||
}
|
||||
|
||||
return {
|
||||
groupedAssets,
|
||||
getGroup,
|
||||
getGroupCount
|
||||
}
|
||||
}
|
||||
@@ -58,13 +58,15 @@ const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateHistory = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn())
|
||||
const mockHasCategory = vi.hoisted(() => vi.fn())
|
||||
const mockPatchApiAsset = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
setAssetDeleting: mockSetAssetDeleting,
|
||||
updateHistory: mockUpdateHistory,
|
||||
updateInputs: mockUpdateInputs,
|
||||
invalidateModelsForCategory: mockInvalidateModelsForCategory,
|
||||
hasCategory: mockHasCategory
|
||||
hasCategory: mockHasCategory,
|
||||
patchApiAsset: mockPatchApiAsset
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -149,11 +151,17 @@ const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
const mockCreateAssetExport = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
|
||||
)
|
||||
const mockAddAssetTags = vi.hoisted(() => vi.fn())
|
||||
const mockRemoveAssetTags = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../services/assetService', () => ({
|
||||
assetService: {
|
||||
deleteAsset: mockDeleteAsset,
|
||||
createAssetExport: mockCreateAssetExport
|
||||
}
|
||||
createAssetExport: mockCreateAssetExport,
|
||||
addAssetTags: mockAddAssetTags,
|
||||
removeAssetTags: mockRemoveAssetTags
|
||||
},
|
||||
OUTPUT_TAG: 'output',
|
||||
TEMP_TAG: 'temp'
|
||||
}))
|
||||
|
||||
const mockTrackExport = vi.hoisted(() => vi.fn())
|
||||
@@ -1066,4 +1074,47 @@ describe('useMediaAssetActions', () => {
|
||||
expect(mockCaptureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('keepPreview', () => {
|
||||
it('adds the output tag before removing temp', async () => {
|
||||
mockAddAssetTags.mockResolvedValue({ total_tags: ['temp', 'output'] })
|
||||
mockRemoveAssetTags.mockResolvedValue({ total_tags: ['output'] })
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({ id: 'temp-1', tags: ['temp'] })
|
||||
|
||||
await actions.keepPreview(asset)
|
||||
|
||||
expect(mockAddAssetTags).toHaveBeenCalledWith('temp-1', ['output'])
|
||||
expect(mockRemoveAssetTags).toHaveBeenCalledWith('temp-1', ['temp'])
|
||||
expect(mockAddAssetTags.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRemoveAssetTags.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('optimistically replaces temp with output in the local cache', async () => {
|
||||
mockAddAssetTags.mockResolvedValue({ total_tags: ['temp', 'output'] })
|
||||
mockRemoveAssetTags.mockResolvedValue({ total_tags: ['output'] })
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({ id: 'temp-1', tags: ['temp'] })
|
||||
|
||||
await actions.keepPreview(asset)
|
||||
|
||||
expect(mockPatchApiAsset).toHaveBeenCalledWith('temp-1', {
|
||||
tags: ['output']
|
||||
})
|
||||
})
|
||||
|
||||
it('reverts the optimistic update when the API calls fail', async () => {
|
||||
mockAddAssetTags.mockRejectedValue(new Error('boom'))
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({ id: 'temp-1', tags: ['temp'] })
|
||||
|
||||
await actions.keepPreview(asset)
|
||||
|
||||
expect(mockRemoveAssetTags).not.toHaveBeenCalled()
|
||||
expect(mockPatchApiAsset).toHaveBeenLastCalledWith('temp-1', {
|
||||
tags: ['temp']
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ import { useAssetExportStore } from '@/stores/assetExportStore'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import { assetService } from '../services/assetService'
|
||||
import { OUTPUT_TAG, TEMP_TAG, assetService } from '../services/assetService'
|
||||
|
||||
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
|
||||
|
||||
@@ -86,6 +86,12 @@ export function useMediaAssetActions() {
|
||||
asset: AssetItem,
|
||||
assetType: string
|
||||
): Promise<void> => {
|
||||
// Assets-API records (job_id is only set by /api/assets) are deleted
|
||||
// directly; history-mapped output assets go through the history API.
|
||||
if (asset.job_id != null) {
|
||||
await assetService.deleteAsset(asset.id)
|
||||
return
|
||||
}
|
||||
if (assetType === 'output') {
|
||||
const jobId =
|
||||
getOutputAssetMetadata(asset.user_metadata)?.jobId || asset.id
|
||||
@@ -160,7 +166,7 @@ export function useMediaAssetActions() {
|
||||
for (const asset of assets) {
|
||||
if (getAssetType(asset) === 'output') {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const jobId = metadata?.jobId || asset.id
|
||||
const jobId = metadata?.jobId || asset.job_id || asset.id
|
||||
if (!jobIds.includes(jobId)) {
|
||||
jobIds.push(jobId)
|
||||
}
|
||||
@@ -176,12 +182,13 @@ export function useMediaAssetActions() {
|
||||
fileCount += 1
|
||||
}
|
||||
|
||||
if (metadata?.jobId && asset.name && metadata.outputCount == null) {
|
||||
if (!jobAssetNameFilters[metadata.jobId]) {
|
||||
jobAssetNameFilters[metadata.jobId] = []
|
||||
const filterJobId = metadata?.jobId || asset.job_id
|
||||
if (filterJobId && asset.name && metadata?.outputCount == null) {
|
||||
if (!jobAssetNameFilters[filterJobId]) {
|
||||
jobAssetNameFilters[filterJobId] = []
|
||||
}
|
||||
if (!jobAssetNameFilters[metadata.jobId].includes(asset.name)) {
|
||||
jobAssetNameFilters[metadata.jobId].push(asset.name)
|
||||
if (!jobAssetNameFilters[filterJobId].includes(asset.name)) {
|
||||
jobAssetNameFilters[filterJobId].push(asset.name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -227,6 +234,40 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a temp (preview) asset to a permanent output asset.
|
||||
* Adds the output tag before removing temp so the asset is never
|
||||
* orphaned out of every tab query, with an optimistic local update.
|
||||
*/
|
||||
const keepPreview = async (asset: AssetItem) => {
|
||||
const assetsStore = useAssetsStore()
|
||||
const originalTags = asset.tags ?? []
|
||||
const keptTags = [
|
||||
...originalTags.filter((tag) => tag !== TEMP_TAG),
|
||||
...(originalTags.includes(OUTPUT_TAG) ? [] : [OUTPUT_TAG])
|
||||
]
|
||||
assetsStore.patchApiAsset(asset.id, { tags: keptTags })
|
||||
|
||||
try {
|
||||
await assetService.addAssetTags(asset.id, [OUTPUT_TAG])
|
||||
await assetService.removeAssetTags(asset.id, [TEMP_TAG])
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.previewKept'),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to keep preview asset:', error)
|
||||
assetsStore.patchApiAsset(asset.id, { tags: originalTags })
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.failedToKeepPreview')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const copyJobId = async (asset?: AssetItem) => {
|
||||
const targetAsset = asset ?? mediaContext?.asset.value
|
||||
if (!targetAsset) return
|
||||
@@ -234,6 +275,7 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const jobId =
|
||||
metadata?.jobId ||
|
||||
targetAsset.job_id ||
|
||||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)
|
||||
|
||||
if (!jobId) {
|
||||
@@ -787,6 +829,7 @@ export function useMediaAssetActions() {
|
||||
return {
|
||||
downloadAssets,
|
||||
deleteAssets,
|
||||
keepPreview,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
addMultipleToWorkflow,
|
||||
|
||||
@@ -7,7 +7,13 @@ import type { Ref } from 'vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest'
|
||||
type SortOption =
|
||||
| 'newest'
|
||||
| 'oldest'
|
||||
| 'longest'
|
||||
| 'fastest'
|
||||
| 'name-asc'
|
||||
| 'name-desc'
|
||||
|
||||
/**
|
||||
* Get timestamp from asset (either create_time or created_at)
|
||||
@@ -81,6 +87,14 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
|
||||
case 'fastest':
|
||||
// Ascending order (fastest execution time first)
|
||||
return sortByUtil(typeFiltered.value, [getAssetExecutionTime])
|
||||
case 'name-asc':
|
||||
return typeFiltered.value.toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
)
|
||||
case 'name-desc':
|
||||
return typeFiltered.value.toSorted((a, b) =>
|
||||
b.name.localeCompare(a.name)
|
||||
)
|
||||
case 'newest':
|
||||
default:
|
||||
// Descending order (newest first) - negate for descending
|
||||
|
||||
@@ -202,4 +202,68 @@ describe('useOutputStacks', () => {
|
||||
child.id
|
||||
])
|
||||
})
|
||||
|
||||
describe('live children overrides (job grouping)', () => {
|
||||
function createApiAsset(id: string, jobId: string | null): AssetItem {
|
||||
return {
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
tags: ['output'],
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
job_id: jobId
|
||||
}
|
||||
}
|
||||
|
||||
it('expands in-memory children without resolving history', async () => {
|
||||
const rep = createApiAsset('rep', 'job-1')
|
||||
const member = createApiAsset('member', 'job-1')
|
||||
const { assetItems, toggleStack } = useOutputStacks({
|
||||
assets: ref([rep]),
|
||||
getJobId: (asset) => asset.job_id ?? null,
|
||||
liveChildren: (asset) => (asset.id === 'rep' ? [member] : [])
|
||||
})
|
||||
|
||||
await toggleStack(rep)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
|
||||
'rep',
|
||||
'member'
|
||||
])
|
||||
expect(assetItems.value[1].isChild).toBe(true)
|
||||
})
|
||||
|
||||
it('does not stack assets whose override job id is null', async () => {
|
||||
const single = createApiAsset('single', null)
|
||||
const { assetItems, toggleStack } = useOutputStacks({
|
||||
assets: ref([single]),
|
||||
getJobId: (asset) => asset.job_id ?? null,
|
||||
liveChildren: () => []
|
||||
})
|
||||
|
||||
await toggleStack(single)
|
||||
|
||||
expect(assetItems.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to history resolution when overrides return undefined', async () => {
|
||||
const parent = createAsset({ id: 'parent' })
|
||||
const child = createAsset({ id: 'child' })
|
||||
mocks.resolveOutputAssetItems.mockResolvedValue([child])
|
||||
|
||||
const { assetItems, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent]),
|
||||
getJobId: () => undefined,
|
||||
liveChildren: () => undefined
|
||||
})
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
|
||||
'parent',
|
||||
'child'
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,9 +16,24 @@ export type OutputStackListItem = {
|
||||
|
||||
type UseOutputStacksOptions = {
|
||||
assets: Ref<AssetItem[]>
|
||||
/**
|
||||
* Override the job id used for stack identity. Return `undefined` to fall
|
||||
* back to the default (history-shaped user_metadata), `null` for no stack.
|
||||
*/
|
||||
getJobId?: (asset: AssetItem) => string | null | undefined
|
||||
/**
|
||||
* Provide stack children synchronously from memory (e.g. job grouping on
|
||||
* the assets-API path). Return `undefined` to fall back to the default
|
||||
* async resolution from job history.
|
||||
*/
|
||||
liveChildren?: (asset: AssetItem) => AssetItem[] | undefined
|
||||
}
|
||||
|
||||
export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
export function useOutputStacks({
|
||||
assets,
|
||||
getJobId,
|
||||
liveChildren
|
||||
}: UseOutputStacksOptions) {
|
||||
const expandedStackJobIds = ref<Set<string>>(new Set())
|
||||
const stackChildrenByJobId = ref<Record<string, AssetItem[]>>({})
|
||||
const loadingStackJobIds = ref<Set<string>>(new Set())
|
||||
@@ -37,7 +52,8 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
continue
|
||||
}
|
||||
|
||||
const children = stackChildrenByJobId.value[jobId] ?? []
|
||||
const children =
|
||||
liveChildren?.(asset) ?? stackChildrenByJobId.value[jobId] ?? []
|
||||
for (const child of children) {
|
||||
items.push({
|
||||
key: `asset-${child.id}`,
|
||||
@@ -55,6 +71,8 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
)
|
||||
|
||||
function getStackJobId(asset: AssetItem): string | null {
|
||||
const override = getJobId?.(asset)
|
||||
if (override !== undefined) return override
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
return metadata?.jobId ?? null
|
||||
}
|
||||
@@ -76,6 +94,13 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
return
|
||||
}
|
||||
|
||||
if (liveChildren?.(asset) !== undefined) {
|
||||
const nextExpanded = new Set(expandedStackJobIds.value)
|
||||
nextExpanded.add(jobId)
|
||||
expandedStackJobIds.value = nextExpanded
|
||||
return
|
||||
}
|
||||
|
||||
if (!stackChildrenByJobId.value[jobId]?.length) {
|
||||
if (loadingStackJobIds.value.has(jobId)) {
|
||||
return
|
||||
|
||||
12
src/platform/assets/constants/assetTags.ts
Normal file
12
src/platform/assets/constants/assetTags.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Reserved asset tags used by the assets API.
|
||||
* Kept dependency-free so components can import them without pulling in
|
||||
* the asset service's network/i18n import chain.
|
||||
*/
|
||||
export const MODELS_TAG = 'models'
|
||||
export const INPUT_TAG = 'input'
|
||||
export const OUTPUT_TAG = 'output'
|
||||
/** Asset tag used by the backend for temporary (preview) workflow outputs. */
|
||||
export const TEMP_TAG = 'temp'
|
||||
/** Asset tag used by the backend for placeholder records that are not installed. */
|
||||
export const MISSING_TAG = 'missing'
|
||||
@@ -10,6 +10,7 @@ const zAsset = z.object({
|
||||
mime_type: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
preview_id: z.string().nullable().optional(),
|
||||
job_id: z.string().nullish(),
|
||||
display_name: z.string().optional(),
|
||||
preview_url: z.string().optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ModelFolder,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { MISSING_TAG, MODELS_TAG } from '@/platform/assets/constants/assetTags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -34,13 +35,29 @@ interface AssetPaginationOptions extends PaginationOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface AssetRequestOptions extends PaginationOptions {
|
||||
export type AssetSortField = 'name' | 'created_at' | 'updated_at' | 'size'
|
||||
export type AssetSortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface AssetListOptions extends PaginationOptions {
|
||||
includeTags: string[]
|
||||
excludeTags?: string[]
|
||||
includePublic?: boolean
|
||||
sort?: AssetSortField
|
||||
order?: AssetSortOrder
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Error from the assets API carrying the HTTP status for fallback decisions. */
|
||||
export class AssetRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AssetRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
interface AssetExportOptions {
|
||||
job_ids?: string[]
|
||||
asset_ids?: AssetId[]
|
||||
@@ -180,11 +197,13 @@ const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make s
|
||||
const DEFAULT_LIMIT = 500
|
||||
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
|
||||
|
||||
export const MODELS_TAG = 'models'
|
||||
export const INPUT_TAG = 'input'
|
||||
export const OUTPUT_TAG = 'output'
|
||||
/** Asset tag used by the backend for placeholder records that are not installed. */
|
||||
export const MISSING_TAG = 'missing'
|
||||
export {
|
||||
MODELS_TAG,
|
||||
INPUT_TAG,
|
||||
OUTPUT_TAG,
|
||||
TEMP_TAG,
|
||||
MISSING_TAG
|
||||
} from '@/platform/assets/constants/assetTags'
|
||||
const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG]
|
||||
|
||||
const uploadedAssetResponseSchema = assetItemSchema.extend({
|
||||
@@ -278,7 +297,7 @@ function createAssetService() {
|
||||
* Handles API response with consistent error handling and Zod validation
|
||||
*/
|
||||
async function handleAssetRequest(
|
||||
options: AssetRequestOptions,
|
||||
options: AssetListOptions,
|
||||
context: string
|
||||
): Promise<AssetResponse> {
|
||||
const {
|
||||
@@ -287,6 +306,8 @@ function createAssetService() {
|
||||
limit = DEFAULT_LIMIT,
|
||||
offset,
|
||||
includePublic,
|
||||
sort,
|
||||
order,
|
||||
signal
|
||||
} = options
|
||||
const normalizedIncludeTags = normalizeAssetTags(includeTags)
|
||||
@@ -305,19 +326,39 @@ function createAssetService() {
|
||||
if (includePublic !== undefined) {
|
||||
queryParams.set('include_public', includePublic ? 'true' : 'false')
|
||||
}
|
||||
if (sort !== undefined) {
|
||||
queryParams.set('sort', sort)
|
||||
}
|
||||
if (order !== undefined) {
|
||||
queryParams.set('order', order)
|
||||
}
|
||||
|
||||
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
|
||||
const res = signal
|
||||
? await api.fetchApi(url, { signal })
|
||||
: await api.fetchApi(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
|
||||
throw new AssetRequestError(
|
||||
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`,
|
||||
res.status
|
||||
)
|
||||
}
|
||||
const data = await res.json()
|
||||
return validateAssetResponse(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets one paginated asset response for arbitrary tag/sort options.
|
||||
* Used by the media sidebar's assets-API path.
|
||||
*/
|
||||
async function getAssetsPage(
|
||||
options: AssetListOptions
|
||||
): Promise<AssetResponse> {
|
||||
return await handleAssetRequest(
|
||||
options,
|
||||
`assets for tags ${options.includeTags.join(',')}`
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Gets a list of model folder keys from the asset API
|
||||
*
|
||||
@@ -948,6 +989,7 @@ function createAssetService() {
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
getAssetsPage,
|
||||
getAssetsPageByTag,
|
||||
getAllAssetsByTag,
|
||||
getInputAssetsIncludingPublic,
|
||||
|
||||
145
src/platform/assets/utils/assetStreamMerge.test.ts
Normal file
145
src/platform/assets/utils/assetStreamMerge.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
compareAssets,
|
||||
mergeAssetStreams,
|
||||
pickNextStream
|
||||
} from '@/platform/assets/utils/assetStreamMerge'
|
||||
import type {
|
||||
AssetSortSpec,
|
||||
AssetStreamState
|
||||
} from '@/platform/assets/utils/assetStreamMerge'
|
||||
|
||||
function asset(id: string, createdAt: string, name = `${id}.png`): AssetItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
tags: ['output'],
|
||||
created_at: createdAt
|
||||
}
|
||||
}
|
||||
|
||||
function stream(
|
||||
tag: string,
|
||||
items: AssetItem[],
|
||||
hasMore = false
|
||||
): AssetStreamState {
|
||||
return { tag, items, offset: items.length, hasMore }
|
||||
}
|
||||
|
||||
const NEWEST_FIRST: AssetSortSpec = { sort: 'created_at', order: 'desc' }
|
||||
const OLDEST_FIRST: AssetSortSpec = { sort: 'created_at', order: 'asc' }
|
||||
const NAME_ASC: AssetSortSpec = { sort: 'name', order: 'asc' }
|
||||
|
||||
describe('compareAssets', () => {
|
||||
it('orders by created_at descending for newest-first', () => {
|
||||
const older = asset('a', '2026-01-01T00:00:00Z')
|
||||
const newer = asset('b', '2026-02-01T00:00:00Z')
|
||||
expect(compareAssets(newer, older, NEWEST_FIRST)).toBeLessThan(0)
|
||||
expect(compareAssets(older, newer, NEWEST_FIRST)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('orders by name ascending for alphabetical sort', () => {
|
||||
const apple = asset('a', '2026-01-01T00:00:00Z', 'apple.png')
|
||||
const zebra = asset('b', '2026-01-01T00:00:00Z', 'zebra.png')
|
||||
expect(compareAssets(apple, zebra, NAME_ASC)).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeAssetStreams', () => {
|
||||
it('merges exhausted streams fully, sorted by spec', () => {
|
||||
const a1 = asset('a1', '2026-03-01T00:00:00Z')
|
||||
const a2 = asset('a2', '2026-01-01T00:00:00Z')
|
||||
const b1 = asset('b1', '2026-02-01T00:00:00Z')
|
||||
const merged = mergeAssetStreams(
|
||||
[stream('output', [a1, a2]), stream('temp', [b1])],
|
||||
NEWEST_FIRST
|
||||
)
|
||||
expect(merged.map((a) => a.id)).toEqual(['a1', 'b1', 'a2'])
|
||||
})
|
||||
|
||||
it('dedupes assets appearing in multiple streams', () => {
|
||||
const shared = asset('x', '2026-01-01T00:00:00Z')
|
||||
const merged = mergeAssetStreams(
|
||||
[stream('output', [shared]), stream('input', [{ ...shared }])],
|
||||
NEWEST_FIRST
|
||||
)
|
||||
expect(merged).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('holds back items beyond the frontier of an unexhausted stream', () => {
|
||||
// Stream A loaded down to Feb; stream B (hasMore) only down to March.
|
||||
// January item from A must wait: B may still hold newer-than-January
|
||||
// items on its next page.
|
||||
const a1 = asset('a1', '2026-04-01T00:00:00Z')
|
||||
const a2 = asset('a2', '2026-02-01T00:00:00Z')
|
||||
const b1 = asset('b1', '2026-03-01T00:00:00Z')
|
||||
const merged = mergeAssetStreams(
|
||||
[stream('output', [a1, a2], false), stream('temp', [b1], true)],
|
||||
NEWEST_FIRST
|
||||
)
|
||||
expect(merged.map((a) => a.id)).toEqual(['a1', 'b1'])
|
||||
})
|
||||
|
||||
it('emits everything when all streams are exhausted', () => {
|
||||
const a1 = asset('a1', '2026-04-01T00:00:00Z')
|
||||
const a2 = asset('a2', '2026-02-01T00:00:00Z')
|
||||
const b1 = asset('b1', '2026-03-01T00:00:00Z')
|
||||
const merged = mergeAssetStreams(
|
||||
[stream('output', [a1, a2]), stream('temp', [b1])],
|
||||
NEWEST_FIRST
|
||||
)
|
||||
expect(merged).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('respects ascending order when holding back', () => {
|
||||
const a1 = asset('a1', '2026-01-01T00:00:00Z')
|
||||
const a2 = asset('a2', '2026-03-01T00:00:00Z')
|
||||
const b1 = asset('b1', '2026-02-01T00:00:00Z')
|
||||
const merged = mergeAssetStreams(
|
||||
[stream('output', [a1, a2], true), stream('temp', [b1], false)],
|
||||
OLDEST_FIRST
|
||||
)
|
||||
// output stream frontier is March (hasMore), temp is exhausted:
|
||||
// everything loaded sorts at or before the frontier.
|
||||
expect(merged.map((a) => a.id)).toEqual(['a1', 'b1', 'a2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickNextStream', () => {
|
||||
it('returns -1 when all streams are exhausted', () => {
|
||||
expect(
|
||||
pickNextStream(
|
||||
[stream('output', [asset('a', '2026-01-01T00:00:00Z')])],
|
||||
NEWEST_FIRST
|
||||
)
|
||||
).toBe(-1)
|
||||
})
|
||||
|
||||
it('prefers an unexhausted stream with nothing loaded', () => {
|
||||
const streams = [
|
||||
stream('output', [asset('a', '2026-01-01T00:00:00Z')], true),
|
||||
stream('temp', [], true)
|
||||
]
|
||||
expect(pickNextStream(streams, NEWEST_FIRST)).toBe(1)
|
||||
})
|
||||
|
||||
it('advances the stream whose frontier is least far along', () => {
|
||||
const streams = [
|
||||
// frontier March — further along under newest-first
|
||||
stream('output', [asset('a', '2026-03-01T00:00:00Z')], true),
|
||||
// frontier April — still near the top
|
||||
stream('temp', [asset('b', '2026-04-01T00:00:00Z')], true)
|
||||
]
|
||||
expect(pickNextStream(streams, NEWEST_FIRST)).toBe(1)
|
||||
})
|
||||
|
||||
it('skips exhausted streams', () => {
|
||||
const streams = [
|
||||
stream('output', [asset('a', '2026-03-01T00:00:00Z')], false),
|
||||
stream('temp', [asset('b', '2026-04-01T00:00:00Z')], true)
|
||||
]
|
||||
expect(pickNextStream(streams, NEWEST_FIRST)).toBe(1)
|
||||
})
|
||||
})
|
||||
117
src/platform/assets/utils/assetStreamMerge.ts
Normal file
117
src/platform/assets/utils/assetStreamMerge.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Pure helpers for merging multiple paginated asset streams (one per tag)
|
||||
* into a single list ordered by the active sort key.
|
||||
*
|
||||
* The assets API's `include_tags` filter uses AND semantics, so showing
|
||||
* outputs together with previews (or the All tab) requires one request
|
||||
* stream per tag, unioned client-side.
|
||||
*/
|
||||
import type {
|
||||
AssetSortField,
|
||||
AssetSortOrder
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
export interface AssetSortSpec {
|
||||
sort: AssetSortField
|
||||
order: AssetSortOrder
|
||||
}
|
||||
|
||||
export interface AssetStreamState {
|
||||
tag: string
|
||||
items: AssetItem[]
|
||||
offset: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
function sortValue(asset: AssetItem, field: AssetSortField): number | string {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return asset.name
|
||||
case 'size':
|
||||
return asset.size ?? 0
|
||||
case 'updated_at':
|
||||
return asset.updated_at ? new Date(asset.updated_at).getTime() : 0
|
||||
case 'created_at':
|
||||
return asset.created_at ? new Date(asset.created_at).getTime() : 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two assets under a sort spec. Returns negative when `a` comes
|
||||
* before `b` in display order.
|
||||
*/
|
||||
export function compareAssets(
|
||||
a: AssetItem,
|
||||
b: AssetItem,
|
||||
spec: AssetSortSpec
|
||||
): number {
|
||||
const va = sortValue(a, spec.sort)
|
||||
const vb = sortValue(b, spec.sort)
|
||||
const cmp =
|
||||
typeof va === 'string' && typeof vb === 'string'
|
||||
? va.localeCompare(vb)
|
||||
: Number(va) - Number(vb)
|
||||
return spec.order === 'asc' ? cmp : -cmp
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge stream items into one deduped list ordered by the sort spec.
|
||||
*
|
||||
* While any stream still has more pages, items sorting after that stream's
|
||||
* frontier (its last loaded item) are held back: they could be preceded by
|
||||
* not-yet-loaded items from that stream, so emitting them would show gaps.
|
||||
*/
|
||||
export function mergeAssetStreams(
|
||||
streams: AssetStreamState[],
|
||||
spec: AssetSortSpec
|
||||
): AssetItem[] {
|
||||
const seen = new Set<string>()
|
||||
const merged: AssetItem[] = []
|
||||
for (const stream of streams) {
|
||||
for (const item of stream.items) {
|
||||
if (seen.has(item.id)) continue
|
||||
seen.add(item.id)
|
||||
merged.push(item)
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => compareAssets(a, b, spec))
|
||||
|
||||
const frontiers = streams
|
||||
.filter((stream) => stream.hasMore && stream.items.length > 0)
|
||||
.map((stream) => stream.items[stream.items.length - 1])
|
||||
if (frontiers.length === 0) return merged
|
||||
|
||||
const limit = frontiers.reduce((min, frontier) =>
|
||||
compareAssets(frontier, min, spec) < 0 ? frontier : min
|
||||
)
|
||||
return merged.filter((item) => compareAssets(item, limit, spec) <= 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the stream to advance on loadMore: the unexhausted stream whose
|
||||
* frontier is least advanced in display order (an unexhausted stream with
|
||||
* nothing loaded yet always wins). Returns -1 when all streams are done.
|
||||
*/
|
||||
export function pickNextStream(
|
||||
streams: AssetStreamState[],
|
||||
spec: AssetSortSpec
|
||||
): number {
|
||||
let best = -1
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
const stream = streams[i]
|
||||
if (!stream.hasMore) continue
|
||||
if (stream.items.length === 0) return i
|
||||
if (best === -1) {
|
||||
best = i
|
||||
continue
|
||||
}
|
||||
const bestItems = streams[best].items
|
||||
const frontier = stream.items[stream.items.length - 1]
|
||||
const bestFrontier = bestItems[bestItems.length - 1]
|
||||
if (compareAssets(frontier, bestFrontier, spec) < 0) {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -29,10 +29,13 @@ export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
|
||||
}> {
|
||||
const baseFilename = asset.name.replace(/\.[^/.]+$/, '.json')
|
||||
|
||||
// For output assets: use jobs API (with caching and validation)
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (metadata?.jobId) {
|
||||
const workflow = await getJobWorkflow(metadata.jobId)
|
||||
// For output assets: use jobs API (with caching and validation).
|
||||
// History-mapped assets carry the job id in user_metadata; assets-API
|
||||
// records expose it as job_id.
|
||||
const jobId =
|
||||
getOutputAssetMetadata(asset.user_metadata)?.jobId ?? asset.job_id
|
||||
if (jobId) {
|
||||
const workflow = await getJobWorkflow(jobId)
|
||||
return { workflow: workflow ?? null, filename: baseFilename }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
AssetRequestError,
|
||||
assetService
|
||||
} from '@/platform/assets/services/assetService'
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -27,11 +30,20 @@ vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
getAssetsByTag: vi.fn(),
|
||||
getAllAssetsByTag: vi.fn(),
|
||||
getAssetsForNodeType: vi.fn(),
|
||||
getAssetsPage: vi.fn(),
|
||||
invalidateInputAssetsIncludingPublic: vi.fn(),
|
||||
updateAsset: vi.fn(),
|
||||
addAssetTags: vi.fn(),
|
||||
removeAssetTags: vi.fn()
|
||||
},
|
||||
AssetRequestError: class AssetRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
},
|
||||
INPUT_TAG: 'input',
|
||||
OUTPUT_TAG: 'output'
|
||||
}))
|
||||
@@ -1635,3 +1647,221 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Assets API union streams', () => {
|
||||
const makeAsset = (
|
||||
id: string,
|
||||
createdAt: string,
|
||||
tags: string[] = ['output']
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
size: 0,
|
||||
tags,
|
||||
created_at: createdAt
|
||||
})
|
||||
|
||||
const page = (assets: AssetItem[], hasMore = false) => ({
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: hasMore
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetches one stream per tag and merges by sort key', async () => {
|
||||
vi.mocked(assetService.getAssetsPage).mockImplementation(
|
||||
async ({ includeTags }) => {
|
||||
if (includeTags[0] === 'output') {
|
||||
return page([
|
||||
makeAsset('o1', '2026-03-01T00:00:00Z'),
|
||||
makeAsset('o2', '2026-01-01T00:00:00Z')
|
||||
])
|
||||
}
|
||||
return page([makeAsset('t1', '2026-02-01T00:00:00Z', ['temp'])])
|
||||
}
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.fetchApiAssets({
|
||||
tags: ['output', 'temp'],
|
||||
sort: 'created_at',
|
||||
order: 'desc'
|
||||
})
|
||||
|
||||
expect(assetService.getAssetsPage).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
vi
|
||||
.mocked(assetService.getAssetsPage)
|
||||
.mock.calls.map(([opts]) => opts.includeTags)
|
||||
).toEqual([['output'], ['temp']])
|
||||
expect(store.apiAssets.map((a) => a.id)).toEqual(['o1', 't1', 'o2'])
|
||||
expect(store.apiHasMore).toBe(false)
|
||||
expect(store.apiLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('passes sort and order through to the service', async () => {
|
||||
vi.mocked(assetService.getAssetsPage).mockResolvedValue(page([]))
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.fetchApiAssets({ tags: ['input'], sort: 'name', order: 'asc' })
|
||||
|
||||
expect(assetService.getAssetsPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sort: 'name', order: 'asc', offset: 0 })
|
||||
)
|
||||
})
|
||||
|
||||
it('loadMore advances the least-advanced stream and dedupes', async () => {
|
||||
vi.mocked(assetService.getAssetsPage).mockImplementation(
|
||||
async ({ includeTags, offset }) => {
|
||||
if (includeTags[0] === 'output') {
|
||||
// First page exhausts the output stream at March.
|
||||
return page([makeAsset('o1', '2026-03-01T00:00:00Z')])
|
||||
}
|
||||
if (offset === undefined || offset === 0) {
|
||||
return page([makeAsset('t1', '2026-04-01T00:00:00Z', ['temp'])], true)
|
||||
}
|
||||
return page([
|
||||
// Duplicate of t1 (offset drift) plus one genuinely new item.
|
||||
makeAsset('t1', '2026-04-01T00:00:00Z', ['temp']),
|
||||
makeAsset('t2', '2026-02-01T00:00:00Z', ['temp'])
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.fetchApiAssets({
|
||||
tags: ['output', 'temp'],
|
||||
sort: 'created_at',
|
||||
order: 'desc'
|
||||
})
|
||||
// temp stream has more and its frontier (April) is least advanced:
|
||||
// o1 (March) is held back until temp catches up.
|
||||
expect(store.apiAssets.map((a) => a.id)).toEqual(['t1'])
|
||||
expect(store.apiHasMore).toBe(true)
|
||||
|
||||
await store.loadMoreApiAssets()
|
||||
|
||||
expect(store.apiAssets.map((a) => a.id)).toEqual(['t1', 'o1', 't2'])
|
||||
expect(store.apiHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('flags the API unavailable on 503', async () => {
|
||||
vi.mocked(assetService.getAssetsPage).mockRejectedValue(
|
||||
new AssetRequestError('unavailable', 503)
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.fetchApiAssets({
|
||||
tags: ['output'],
|
||||
sort: 'created_at',
|
||||
order: 'desc'
|
||||
})
|
||||
|
||||
expect(store.assetApiUnavailable).toBe(true)
|
||||
expect(store.apiError).toBeInstanceOf(AssetRequestError)
|
||||
})
|
||||
|
||||
it('does not flag unavailability on transient server errors', async () => {
|
||||
vi.mocked(assetService.getAssetsPage).mockRejectedValue(
|
||||
new AssetRequestError('boom', 500)
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.fetchApiAssets({
|
||||
tags: ['output'],
|
||||
sort: 'created_at',
|
||||
order: 'desc'
|
||||
})
|
||||
|
||||
expect(store.assetApiUnavailable).toBe(false)
|
||||
expect(store.apiError).toBeInstanceOf(AssetRequestError)
|
||||
})
|
||||
|
||||
it('refreshApiAssets refetches the same streams from the start', async () => {
|
||||
vi.mocked(assetService.getAssetsPage)
|
||||
.mockResolvedValueOnce(page([makeAsset('o1', '2026-02-01T00:00:00Z')]))
|
||||
.mockResolvedValueOnce(
|
||||
page([
|
||||
makeAsset('o2', '2026-03-01T00:00:00Z'),
|
||||
makeAsset('o1', '2026-02-01T00:00:00Z')
|
||||
])
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.fetchApiAssets({
|
||||
tags: ['output'],
|
||||
sort: 'created_at',
|
||||
order: 'desc'
|
||||
})
|
||||
await store.refreshApiAssets()
|
||||
|
||||
expect(assetService.getAssetsPage).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
includeTags: ['output'],
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
offset: 0
|
||||
})
|
||||
)
|
||||
expect(store.apiAssets.map((a) => a.id)).toEqual(['o2', 'o1'])
|
||||
})
|
||||
|
||||
it('refreshApiAssets is a no-op before the first fetch', async () => {
|
||||
const store = useAssetsStore()
|
||||
await store.refreshApiAssets()
|
||||
expect(assetService.getAssetsPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('patchApiAsset updates loaded assets in place', async () => {
|
||||
vi.mocked(assetService.getAssetsPage).mockResolvedValue(
|
||||
page([makeAsset('t1', '2026-02-01T00:00:00Z', ['temp'])])
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.fetchApiAssets({
|
||||
tags: ['temp'],
|
||||
sort: 'created_at',
|
||||
order: 'desc'
|
||||
})
|
||||
store.patchApiAsset('t1', { tags: ['output'] })
|
||||
|
||||
expect(store.apiAssets[0].tags).toEqual(['output'])
|
||||
})
|
||||
|
||||
it('ignores stale responses when a newer fetch supersedes', async () => {
|
||||
let resolveFirst!: (value: {
|
||||
assets: AssetItem[]
|
||||
total: number
|
||||
has_more: boolean
|
||||
}) => void
|
||||
vi.mocked(assetService.getAssetsPage)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((res) => {
|
||||
resolveFirst = res
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(page([makeAsset('b1', '2026-01-01T00:00:00Z')]))
|
||||
|
||||
const store = useAssetsStore()
|
||||
const first = store.fetchApiAssets({
|
||||
tags: ['output'],
|
||||
sort: 'created_at',
|
||||
order: 'desc'
|
||||
})
|
||||
const second = store.fetchApiAssets({
|
||||
tags: ['input'],
|
||||
sort: 'created_at',
|
||||
order: 'desc'
|
||||
})
|
||||
|
||||
resolveFirst(page([makeAsset('a1', '2026-02-01T00:00:00Z')]))
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(store.apiAssets.map((a) => a.id)).toEqual(['b1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,12 +11,26 @@ import type {
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
AssetRequestError,
|
||||
INPUT_TAG,
|
||||
OUTPUT_TAG,
|
||||
assetService
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import type { PaginationOptions } from '@/platform/assets/services/assetService'
|
||||
import type {
|
||||
AssetSortField,
|
||||
AssetSortOrder,
|
||||
PaginationOptions
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
mergeAssetStreams,
|
||||
pickNextStream
|
||||
} from '@/platform/assets/utils/assetStreamMerge'
|
||||
import type {
|
||||
AssetSortSpec,
|
||||
AssetStreamState
|
||||
} from '@/platform/assets/utils/assetStreamMerge'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -319,6 +333,144 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
await fetchFlatOutputs(true)
|
||||
}
|
||||
|
||||
// Assets-API path for the media sidebar: one paginated stream per tag
|
||||
// (include_tags is AND semantics, so output+temp / All need a client-side
|
||||
// union), merged by the active sort key and deduped by asset id.
|
||||
const UNION_PAGE_SIZE = 100
|
||||
|
||||
const apiAssets = ref<AssetItem[]>([])
|
||||
const apiLoading = ref(false)
|
||||
const apiError = ref<unknown>(null)
|
||||
const apiHasMore = ref(false)
|
||||
const apiIsLoadingMore = ref(false)
|
||||
/**
|
||||
* Session-scoped flag set when the assets API is unreachable (503 from OSS
|
||||
* servers without --enable-assets, 404 from older servers). The sidebar
|
||||
* falls back to the history path for the rest of the session.
|
||||
*/
|
||||
const assetApiUnavailable = ref(false)
|
||||
|
||||
/**
|
||||
* Whether the media sidebar is on the assets-API data source: setting on
|
||||
* and the API not detected as unavailable this session.
|
||||
*/
|
||||
const assetApiSourceActive = computed(
|
||||
() =>
|
||||
!!useSettingStore().get('Comfy.Assets.UseAssetAPI') &&
|
||||
!assetApiUnavailable.value
|
||||
)
|
||||
|
||||
let apiStreams: AssetStreamState[] = []
|
||||
let apiSortSpec: AssetSortSpec = { sort: 'created_at', order: 'desc' }
|
||||
let apiRequestId = 0
|
||||
|
||||
async function fetchStreamPage(
|
||||
stream: AssetStreamState,
|
||||
spec: AssetSortSpec
|
||||
): Promise<void> {
|
||||
const page = await assetService.getAssetsPage({
|
||||
includeTags: [stream.tag],
|
||||
limit: UNION_PAGE_SIZE,
|
||||
offset: stream.offset,
|
||||
sort: spec.sort,
|
||||
order: spec.order
|
||||
})
|
||||
stream.items.push(...page.assets)
|
||||
stream.offset += page.assets.length
|
||||
stream.hasMore = page.has_more && page.assets.length > 0
|
||||
}
|
||||
|
||||
function syncApiAssets() {
|
||||
apiAssets.value = mergeAssetStreams(apiStreams, apiSortSpec)
|
||||
apiHasMore.value = apiStreams.some((stream) => stream.hasMore)
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)load the union streams for the given tags and sort. Replaces any
|
||||
* in-flight or previous result.
|
||||
*/
|
||||
async function fetchApiAssets(params: {
|
||||
tags: string[]
|
||||
sort: AssetSortField
|
||||
order: AssetSortOrder
|
||||
}): Promise<void> {
|
||||
const requestId = ++apiRequestId
|
||||
const spec: AssetSortSpec = { sort: params.sort, order: params.order }
|
||||
const streams: AssetStreamState[] = params.tags.map((tag) => ({
|
||||
tag,
|
||||
items: [],
|
||||
offset: 0,
|
||||
hasMore: true
|
||||
}))
|
||||
|
||||
apiLoading.value = true
|
||||
apiError.value = null
|
||||
try {
|
||||
await Promise.all(streams.map((stream) => fetchStreamPage(stream, spec)))
|
||||
if (requestId !== apiRequestId) return
|
||||
apiStreams = streams
|
||||
apiSortSpec = spec
|
||||
syncApiAssets()
|
||||
} catch (err) {
|
||||
if (requestId !== apiRequestId) return
|
||||
apiError.value = err
|
||||
if (
|
||||
err instanceof AssetRequestError &&
|
||||
(err.status === 503 || err.status === 404)
|
||||
) {
|
||||
assetApiUnavailable.value = true
|
||||
}
|
||||
console.error('Failed to fetch assets from assets API:', err)
|
||||
} finally {
|
||||
if (requestId === apiRequestId) apiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch the current streams from the start with the same tags and sort.
|
||||
* Used when new assets may exist server-side (e.g. a job completed).
|
||||
* No-op until the sidebar has performed its first fetch.
|
||||
*/
|
||||
async function refreshApiAssets(): Promise<void> {
|
||||
if (apiStreams.length === 0) return
|
||||
await fetchApiAssets({
|
||||
tags: apiStreams.map((stream) => stream.tag),
|
||||
sort: apiSortSpec.sort,
|
||||
order: apiSortSpec.order
|
||||
})
|
||||
}
|
||||
|
||||
/** Advance the stream whose frontier is least far along in sort order. */
|
||||
async function loadMoreApiAssets(): Promise<void> {
|
||||
if (apiLoading.value || apiIsLoadingMore.value) return
|
||||
const index = pickNextStream(apiStreams, apiSortSpec)
|
||||
if (index === -1) return
|
||||
|
||||
const requestId = apiRequestId
|
||||
apiIsLoadingMore.value = true
|
||||
try {
|
||||
await fetchStreamPage(apiStreams[index], apiSortSpec)
|
||||
if (requestId !== apiRequestId) return
|
||||
syncApiAssets()
|
||||
} catch (err) {
|
||||
if (requestId !== apiRequestId) return
|
||||
apiError.value = err
|
||||
console.error('Failed to load more assets from assets API:', err)
|
||||
} finally {
|
||||
apiIsLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistically patch an asset across loaded streams (e.g. tag changes). */
|
||||
function patchApiAsset(id: string, updates: Partial<AssetItem>): void {
|
||||
for (const stream of apiStreams) {
|
||||
stream.items = stream.items.map((item) =>
|
||||
item.id === id ? { ...item, ...updates } : item
|
||||
)
|
||||
}
|
||||
syncApiAssets()
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch preview_id/preview_url for a single asset already in memory,
|
||||
* matched by name. Used after persistThumbnail succeeds so an open Asset
|
||||
@@ -875,6 +1027,19 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
loadMoreHistory,
|
||||
setAssetPreview,
|
||||
|
||||
// Assets-API union streams (media sidebar)
|
||||
apiAssets,
|
||||
apiLoading,
|
||||
apiError,
|
||||
apiHasMore,
|
||||
apiIsLoadingMore,
|
||||
assetApiUnavailable,
|
||||
assetApiSourceActive,
|
||||
fetchApiAssets,
|
||||
refreshApiAssets,
|
||||
loadMoreApiAssets,
|
||||
patchApiAsset,
|
||||
|
||||
// Flat output assets (cloud-only, tag-based)
|
||||
flatOutputAssets,
|
||||
flatOutputLoading,
|
||||
|
||||
@@ -231,23 +231,30 @@ useQueuePolling()
|
||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
// Only update assets if the assets sidebar is currently open (it refreshes
|
||||
// on mount otherwise). Linear mode always consumes history-shaped assets;
|
||||
// the sidebar consumes whichever data source is active.
|
||||
const refreshOpenAssetViews = async () => {
|
||||
const assetsTabOpen = sidebarTabStore.activeSidebarTabId === 'assets'
|
||||
if (!assetsTabOpen && !linearMode.value) return
|
||||
|
||||
if (linearMode.value || !assetsStore.assetApiSourceActive) {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
if (assetsTabOpen && assetsStore.assetApiSourceActive) {
|
||||
await assetsStore.refreshApiAssets()
|
||||
}
|
||||
}
|
||||
|
||||
const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
|
||||
queuePendingTaskCountStore.update(e)
|
||||
await queueStore.update()
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
await refreshOpenAssetViews()
|
||||
}
|
||||
|
||||
const onExecutionSuccess = async () => {
|
||||
await queueStore.update()
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
await refreshOpenAssetViews()
|
||||
}
|
||||
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
Reference in New Issue
Block a user