mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 01:39:47 +00:00
feat: Add pagination support for media assets history (#6373)
## Summary - Implement pagination for media assets history to handle large datasets efficiently - Add infinite scroll support with approach-end event handler - Support offset parameter in history API for both V1 and V2 endpoints ## Changes - Add offset parameter support to `api.getHistory()` method - Update history fetchers (V1/V2) to include offset in API requests - Implement `loadMoreHistory()` in assetsStore with pagination state management - Add `loadMore`, `hasMore`, and `isLoadingMore` to IAssetsProvider interface - Add approach-end handler in AssetsSidebarTab for infinite scroll - Set BATCH_SIZE to 200 for efficient loading ## Implementation Improvements Simplified offset-based pagination by removing unnecessary reconciliation logic: - Remove `reconcileHistory`, `taskItemsMap`, `lastKnownQueueIndex` (offset is sufficient) - Replace `assetItemsByPromptId` Map → `loadedIds` Set (store IDs only) - Replace `findInsertionIndex` binary search → push + sort (faster for batch operations) - Replace `loadingPromise` → `isLoadingMore` boolean (simpler state management) - Fix memory leak by cleaning up Set together with array slice ## Test Plan - [x] TypeScript compilation passes - [x] ESLint and Prettier formatting applied - [x] Test infinite scroll in media assets tab - [x] Verify network requests include correct offset parameter - [x] Confirm no duplicate items when loading more 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive } from 'vue'
|
||||
|
||||
import { computed, shallowReactive, ref } from 'vue'
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
mapTaskOutputToAssetItem
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { HistoryTaskItem } from '@/schemas/apiSchema'
|
||||
import type { TaskItem } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { TaskItemImpl } from './queueStore'
|
||||
@@ -48,10 +47,15 @@ async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
/**
|
||||
* Convert history task items to asset items
|
||||
*/
|
||||
function mapHistoryToAssets(historyItems: HistoryTaskItem[]): AssetItem[] {
|
||||
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
|
||||
}
|
||||
@@ -85,16 +89,22 @@ function mapHistoryToAssets(historyItems: HistoryTaskItem[]): AssetItem[] {
|
||||
)
|
||||
}
|
||||
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
const maxHistoryItems = 200
|
||||
const BATCH_SIZE = 200
|
||||
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
|
||||
|
||||
const getFetchInputFiles = () => {
|
||||
if (isCloud) {
|
||||
return fetchInputFilesFromCloud
|
||||
}
|
||||
return fetchInputFilesFromAPI
|
||||
}
|
||||
const fetchInputFiles = getFetchInputFiles()
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
// 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,
|
||||
@@ -109,23 +119,119 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const fetchHistoryAssets = async (): Promise<AssetItem[]> => {
|
||||
const history = await api.getHistory(maxHistoryItems)
|
||||
return mapHistoryToAssets(history.History)
|
||||
/**
|
||||
* 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 {
|
||||
state: historyAssets,
|
||||
isLoading: historyLoading,
|
||||
error: historyError,
|
||||
execute: updateHistory
|
||||
} = useAsyncState(fetchHistoryAssets, [], {
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
onError: (err) => {
|
||||
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
|
||||
@@ -142,7 +248,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Get human-readable name for input asset filename
|
||||
* @param filename Hash-based filename (e.g., "72e786ff...efb7.png")
|
||||
* @returns Human-readable asset name or original filename if not found
|
||||
*/
|
||||
@@ -248,10 +353,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
historyLoading,
|
||||
inputError,
|
||||
historyError,
|
||||
hasMoreHistory,
|
||||
isLoadingMore,
|
||||
|
||||
// Actions
|
||||
updateInputs,
|
||||
updateHistory,
|
||||
loadMoreHistory,
|
||||
|
||||
// Input mapping helpers
|
||||
inputAssetsByFilename,
|
||||
|
||||
Reference in New Issue
Block a user