mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 01:50:08 +00:00
## Summary
Refactors asset download state management and fixes asset deletion UI
issues.
## Changes
### assetDownloadStore simplification
- Replace `pendingModelTypes` Map with `modelType` stored directly on
`AssetDownload`
- Replace `completedDownloads` array with single `lastCompletedDownload`
ref
- `trackDownload()` now creates a placeholder entry immediately
- Use VueUse `whenever` instead of `watch` for cleaner null handling
### Asset refresh on download completion
- Refresh all relevant caches when a download completes:
- Node type caches (e.g., "CheckpointLoaderSimple")
- Tag caches (e.g., "tag:checkpoints")
- "All Models" cache ("tag:models")
### Asset deletion fix
- Remove local `deletedLocal` state that caused blank grid cells
- Emit `deleted` event from AssetCard → AssetGrid → AssetBrowserModal
- Trigger store refresh on deletion to properly remove the asset from
the grid
## Testing
- Added test for out-of-order websocket message handling
- All existing tests pass
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7974-refactor-simplify-asset-download-state-and-fix-deletion-UI-2e76d73d365081c69bcde9150a0d460c)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Amp <amp@ampcode.com>
443 lines
12 KiB
TypeScript
443 lines
12 KiB
TypeScript
import { useAsyncState, whenever } from '@vueuse/core'
|
|
import { isEqual } from 'es-toolkit'
|
|
import { defineStore } from 'pinia'
|
|
import { computed, shallowReactive, ref } from 'vue'
|
|
import {
|
|
mapInputFileToAssetItem,
|
|
mapTaskOutputToAssetItem
|
|
} from '@/platform/assets/composables/media/assetMappers'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
import { assetService } from '@/platform/assets/services/assetService'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
import type { TaskItem } from '@/schemas/apiSchema'
|
|
import { api } from '@/scripts/api'
|
|
|
|
import { TaskItemImpl } from './queueStore'
|
|
import { useAssetDownloadStore } from './assetDownloadStore'
|
|
import { useModelToNodeStore } from './modelToNodeStore'
|
|
|
|
const INPUT_LIMIT = 100
|
|
|
|
/**
|
|
* Fetch input files from the internal API (OSS version)
|
|
*/
|
|
async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
|
|
const response = await fetch(api.internalURL('/files/input'), {
|
|
headers: {
|
|
'Comfy-User': api.user
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch input files')
|
|
}
|
|
|
|
const filenames: string[] = await response.json()
|
|
return filenames.map((name, index) =>
|
|
mapInputFileToAssetItem(name, index, 'input')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Fetch input files from cloud service
|
|
*/
|
|
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
|
return await assetService.getAssetsByTag('input', false, {
|
|
limit: INPUT_LIMIT
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Convert history task items to asset items
|
|
*/
|
|
function mapHistoryToAssets(historyItems: TaskItem[]): AssetItem[] {
|
|
const assetItems: AssetItem[] = []
|
|
|
|
for (const item of historyItems) {
|
|
// Type guard for HistoryTaskItem which has status and outputs
|
|
if (item.taskType !== 'History') {
|
|
continue
|
|
}
|
|
|
|
if (!item.outputs || !item.status || item.status?.status_str === 'error') {
|
|
continue
|
|
}
|
|
|
|
const task = new TaskItemImpl(
|
|
'History',
|
|
item.prompt,
|
|
item.status,
|
|
item.outputs
|
|
)
|
|
|
|
if (!task.previewOutput) {
|
|
continue
|
|
}
|
|
|
|
const assetItem = mapTaskOutputToAssetItem(task, task.previewOutput)
|
|
|
|
const supportedOutputs = task.flatOutputs.filter((o) => o.supportsPreview)
|
|
assetItem.user_metadata = {
|
|
...assetItem.user_metadata,
|
|
outputCount: supportedOutputs.length,
|
|
allOutputs: supportedOutputs
|
|
}
|
|
|
|
assetItems.push(assetItem)
|
|
}
|
|
|
|
return assetItems.sort(
|
|
(a, b) =>
|
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
)
|
|
}
|
|
|
|
const BATCH_SIZE = 200
|
|
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
|
|
|
|
export const useAssetsStore = defineStore('assets', () => {
|
|
const assetDownloadStore = useAssetDownloadStore()
|
|
const modelToNodeStore = useModelToNodeStore()
|
|
|
|
// Pagination state
|
|
const historyOffset = ref(0)
|
|
const hasMoreHistory = ref(true)
|
|
const isLoadingMore = ref(false)
|
|
|
|
const allHistoryItems = ref<AssetItem[]>([])
|
|
|
|
const loadedIds = shallowReactive(new Set<string>())
|
|
|
|
const fetchInputFiles = isCloud
|
|
? fetchInputFilesFromCloud
|
|
: fetchInputFilesFromAPI
|
|
|
|
const {
|
|
state: inputAssets,
|
|
isLoading: inputLoading,
|
|
error: inputError,
|
|
execute: updateInputs
|
|
} = useAsyncState(fetchInputFiles, [], {
|
|
immediate: false,
|
|
resetOnExecute: false,
|
|
onError: (err) => {
|
|
console.error('Error fetching input assets:', err)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Fetch history assets with pagination support
|
|
* @param loadMore - true for pagination (append), false for initial load (replace)
|
|
*/
|
|
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
|
|
// Reset state for initial load
|
|
if (!loadMore) {
|
|
historyOffset.value = 0
|
|
hasMoreHistory.value = true
|
|
allHistoryItems.value = []
|
|
loadedIds.clear()
|
|
}
|
|
|
|
// Fetch from server with offset
|
|
const history = await api.getHistory(BATCH_SIZE, {
|
|
offset: historyOffset.value
|
|
})
|
|
|
|
// Convert TaskItems to AssetItems
|
|
const newAssets = mapHistoryToAssets(history.History)
|
|
|
|
if (loadMore) {
|
|
// Filter out duplicates and insert in sorted order
|
|
for (const asset of newAssets) {
|
|
if (loadedIds.has(asset.id)) {
|
|
continue // Skip duplicates
|
|
}
|
|
loadedIds.add(asset.id)
|
|
|
|
// Find insertion index to maintain sorted order (newest first)
|
|
const assetTime = new Date(asset.created_at).getTime()
|
|
const insertIndex = allHistoryItems.value.findIndex(
|
|
(item) => new Date(item.created_at).getTime() < assetTime
|
|
)
|
|
|
|
if (insertIndex === -1) {
|
|
// Asset is oldest, append to end
|
|
allHistoryItems.value.push(asset)
|
|
} else {
|
|
// Insert at the correct position
|
|
allHistoryItems.value.splice(insertIndex, 0, asset)
|
|
}
|
|
}
|
|
} else {
|
|
// Initial load: replace all
|
|
allHistoryItems.value = newAssets
|
|
newAssets.forEach((asset) => loadedIds.add(asset.id))
|
|
}
|
|
|
|
// Update pagination state
|
|
historyOffset.value += BATCH_SIZE
|
|
hasMoreHistory.value = history.History.length === BATCH_SIZE
|
|
|
|
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
|
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
|
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
|
|
|
|
// Clean up Set
|
|
removed.forEach((item) => loadedIds.delete(item.id))
|
|
}
|
|
|
|
return allHistoryItems.value
|
|
}
|
|
|
|
const historyAssets = ref<AssetItem[]>([])
|
|
const historyLoading = ref(false)
|
|
const historyError = ref<unknown>(null)
|
|
|
|
/**
|
|
* Initial load of history assets
|
|
*/
|
|
const updateHistory = async () => {
|
|
historyLoading.value = true
|
|
historyError.value = null
|
|
try {
|
|
await fetchHistoryAssets(false)
|
|
historyAssets.value = allHistoryItems.value
|
|
} catch (err) {
|
|
console.error('Error fetching history assets:', err)
|
|
historyError.value = err
|
|
// Keep existing data when error occurs
|
|
if (!historyAssets.value.length) {
|
|
historyAssets.value = []
|
|
}
|
|
} finally {
|
|
historyLoading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load more history items (infinite scroll)
|
|
*/
|
|
const loadMoreHistory = async () => {
|
|
// Guard: prevent concurrent loads and check if more items available
|
|
if (!hasMoreHistory.value || isLoadingMore.value) return
|
|
|
|
isLoadingMore.value = true
|
|
historyError.value = null
|
|
|
|
try {
|
|
await fetchHistoryAssets(true)
|
|
historyAssets.value = allHistoryItems.value
|
|
} catch (err) {
|
|
console.error('Error loading more history:', err)
|
|
historyError.value = err
|
|
// Keep existing data when error occurs (consistent with updateHistory)
|
|
if (!historyAssets.value.length) {
|
|
historyAssets.value = []
|
|
}
|
|
} finally {
|
|
isLoadingMore.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map of asset hash filename to asset item for O(1) lookup
|
|
* Cloud assets use asset_hash for the hash-based filename
|
|
*/
|
|
const inputAssetsByFilename = computed(() => {
|
|
const map = new Map<string, AssetItem>()
|
|
for (const asset of inputAssets.value) {
|
|
if (asset.asset_hash) {
|
|
map.set(asset.asset_hash, asset)
|
|
}
|
|
}
|
|
return map
|
|
})
|
|
|
|
/**
|
|
* @param filename Hash-based filename (e.g., "72e786ff...efb7.png")
|
|
* @returns Human-readable asset name or original filename if not found
|
|
*/
|
|
function getInputName(filename: string): string {
|
|
return inputAssetsByFilename.value.get(filename)?.name ?? filename
|
|
}
|
|
|
|
/**
|
|
* Model assets cached by node type (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
|
|
* Used by multiple loader nodes to avoid duplicate fetches
|
|
* Cloud-only feature - empty Maps in desktop builds
|
|
*/
|
|
const getModelState = () => {
|
|
if (isCloud) {
|
|
const modelAssetsByNodeType = shallowReactive(
|
|
new Map<string, AssetItem[]>()
|
|
)
|
|
const modelLoadingByNodeType = shallowReactive(new Map<string, boolean>())
|
|
const modelErrorByNodeType = shallowReactive(
|
|
new Map<string, Error | null>()
|
|
)
|
|
|
|
const stateByNodeType = shallowReactive(
|
|
new Map<string, ReturnType<typeof useAsyncState<AssetItem[]>>>()
|
|
)
|
|
|
|
/**
|
|
* Internal helper to fetch and cache assets with a given key and fetcher
|
|
*/
|
|
async function updateModelsForKey(
|
|
key: string,
|
|
fetcher: () => Promise<AssetItem[]>
|
|
): Promise<AssetItem[]> {
|
|
if (!stateByNodeType.has(key)) {
|
|
stateByNodeType.set(
|
|
key,
|
|
useAsyncState(fetcher, [], {
|
|
immediate: false,
|
|
resetOnExecute: false,
|
|
onError: (err) => {
|
|
console.error(`Error fetching model assets for ${key}:`, err)
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
const state = stateByNodeType.get(key)!
|
|
|
|
modelLoadingByNodeType.set(key, true)
|
|
modelErrorByNodeType.set(key, null)
|
|
|
|
try {
|
|
await state.execute()
|
|
} finally {
|
|
modelLoadingByNodeType.set(key, state.isLoading.value)
|
|
}
|
|
|
|
const assets = state.state.value
|
|
const existingAssets = modelAssetsByNodeType.get(key)
|
|
|
|
if (!isEqual(existingAssets, assets)) {
|
|
modelAssetsByNodeType.set(key, assets)
|
|
}
|
|
|
|
modelErrorByNodeType.set(
|
|
key,
|
|
state.error.value instanceof Error ? state.error.value : null
|
|
)
|
|
|
|
return assets
|
|
}
|
|
|
|
/**
|
|
* Fetch and cache model assets for a specific node type
|
|
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
|
* @returns Promise resolving to the fetched assets
|
|
*/
|
|
async function updateModelsForNodeType(
|
|
nodeType: string
|
|
): Promise<AssetItem[]> {
|
|
return updateModelsForKey(nodeType, () =>
|
|
assetService.getAssetsForNodeType(nodeType)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Fetch and cache model assets for a specific tag
|
|
* @param tag The tag to fetch assets for (e.g., 'models')
|
|
* @returns Promise resolving to the fetched assets
|
|
*/
|
|
async function updateModelsForTag(tag: string): Promise<AssetItem[]> {
|
|
const key = `tag:${tag}`
|
|
return updateModelsForKey(key, () => assetService.getAssetsByTag(tag))
|
|
}
|
|
|
|
return {
|
|
modelAssetsByNodeType,
|
|
modelLoadingByNodeType,
|
|
modelErrorByNodeType,
|
|
updateModelsForNodeType,
|
|
updateModelsForTag
|
|
}
|
|
}
|
|
|
|
return {
|
|
modelAssetsByNodeType: shallowReactive(new Map<string, AssetItem[]>()),
|
|
modelLoadingByNodeType: shallowReactive(new Map<string, boolean>()),
|
|
modelErrorByNodeType: shallowReactive(new Map<string, Error | null>()),
|
|
updateModelsForNodeType: async () => [],
|
|
updateModelsForTag: async () => []
|
|
}
|
|
}
|
|
|
|
const {
|
|
modelAssetsByNodeType,
|
|
modelLoadingByNodeType,
|
|
modelErrorByNodeType,
|
|
updateModelsForNodeType,
|
|
updateModelsForTag
|
|
} = getModelState()
|
|
|
|
// Watch for completed downloads and refresh model caches
|
|
whenever(
|
|
() => assetDownloadStore.lastCompletedDownload,
|
|
async (latestDownload) => {
|
|
const { modelType } = latestDownload
|
|
|
|
const providers = modelToNodeStore
|
|
.getAllNodeProviders(modelType)
|
|
.filter((provider) => provider.nodeDef?.name)
|
|
|
|
const nodeTypeUpdates = providers.map((provider) =>
|
|
updateModelsForNodeType(provider.nodeDef.name).then(
|
|
() => provider.nodeDef.name
|
|
)
|
|
)
|
|
|
|
// Also update by tag in case modal was opened with assetType
|
|
const tagUpdates = [
|
|
updateModelsForTag(modelType),
|
|
updateModelsForTag('models')
|
|
]
|
|
|
|
const results = await Promise.allSettled([
|
|
...nodeTypeUpdates,
|
|
...tagUpdates
|
|
])
|
|
|
|
for (const result of results) {
|
|
if (result.status === 'rejected') {
|
|
console.error(
|
|
`Failed to refresh model cache for provider: ${result.reason}`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
return {
|
|
// States
|
|
inputAssets,
|
|
historyAssets,
|
|
inputLoading,
|
|
historyLoading,
|
|
inputError,
|
|
historyError,
|
|
hasMoreHistory,
|
|
isLoadingMore,
|
|
|
|
// Actions
|
|
updateInputs,
|
|
updateHistory,
|
|
loadMoreHistory,
|
|
|
|
// Input mapping helpers
|
|
inputAssetsByFilename,
|
|
getInputName,
|
|
|
|
// Model assets
|
|
modelAssetsByNodeType,
|
|
modelLoadingByNodeType,
|
|
modelErrorByNodeType,
|
|
updateModelsForNodeType,
|
|
updateModelsForTag
|
|
}
|
|
})
|