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:
Jin Yi
2025-11-14 04:15:44 +09:00
committed by GitHub
parent e639577685
commit f0f554392d
15 changed files with 735 additions and 222 deletions

View File

@@ -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,