Compare commits

...

6 Commits

Author SHA1 Message Date
Jin Yi
b0940b89db fix: route unkeyed output assets through asset_ids in zip export
When an output asset has no prompt_id and no metadata.jobId, push it
to asset_ids instead of job_ids so the export API can locate it by
its actual asset identifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:19:34 +09:00
Jin Yi
f1442817a6 Merge branch 'main' into media-asset/asset-api 2026-03-27 15:07:30 +09:00
Jin Yi
5e318e1a36 fix: address code review findings from PR #10432
- Add prompt_id fallback to download/export ZIP paths
- Fix pagination merge to dedupe by prompt_id across pages
- Fix dragStart to use getAssetUrl fallback for cloud assets
- Strengthen useOutputStacks test to verify prompt_id key priority
- Add error response test for getAssetsByJobIds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:04:31 +09:00
Jin Yi
733b2a259c fix: add missing createI18n to vue-i18n mock in AssetsSidebarListView test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:31:13 +09:00
Jin Yi
f75cf28c87 Merge branch 'main' into media-asset/asset-api 2026-03-27 14:20:53 +09:00
Jin Yi
a17b8a73f5 refactor: switch cloud history from Jobs API to Assets API
- Add prompt_id field to asset schema
  - Add getAssetsByJobIds and fetchPromptAssets to asset service with LRU cache
  - Split history fetch into cloud (Assets API + groupByPromptId) and OSS (Jobs API)
  - Extract resolveAssetOutputs composable for cloud/OSS output resolution
  - Enable individual asset deletion in cloud (was whole-job deletion)
  - Add video preview URL fallback via getAssetUrl for cloud assets
2026-03-24 16:31:12 +09:00
17 changed files with 588 additions and 105 deletions

View File

@@ -10,6 +10,11 @@ import AssetsSidebarListView from './AssetsSidebarListView.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: () => ({
global: {
t: (key: string) => key
}
})
}))

View File

@@ -77,6 +77,7 @@ import type { OutputStackListItem } from '@/platform/assets/composables/useOutpu
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import {
@@ -135,7 +136,7 @@ function isVideoAsset(asset: AssetItem): boolean {
function getAssetPreviewUrl(asset: AssetItem): string {
const mediaType = getAssetMediaType(asset)
if (mediaType === 'image' || mediaType === 'video') {
return asset.preview_url || ''
return asset.preview_url || getAssetUrl(asset)
}
return ''
}
@@ -158,13 +159,13 @@ function getAssetSecondaryText(asset: AssetItem): string {
return ''
}
// OSS: metadata?.outputCount (typed). Cloud: raw user_metadata fallback.
function getStackCount(asset: AssetItem): number | undefined {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.outputCount === 'number') {
return metadata.outputCount
}
const count = metadata?.outputCount ?? asset.user_metadata?.outputCount
if (typeof count === 'number' && count > 1) return count
if (Array.isArray(metadata?.allOutputs)) {
if (Array.isArray(metadata?.allOutputs) && metadata.allOutputs.length > 1) {
return metadata.allOutputs.length
}

View File

@@ -26,8 +26,8 @@
</template>
<template #header>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="px-2 2xl:px-4">
<Button variant="secondary" size="lg" @click="exitFolderView">
<div v-if="isInFolderView" class="px-2 pt-2 2xl:px-4">
<Button variant="secondary" @click="exitFolderView">
<i class="icon-[lucide--arrow-left] size-4" />
<span>{{ $t('sideToolbar.backToAssets') }}</span>
</Button>
@@ -232,12 +232,12 @@ import { useAssetSelection } from '@/platform/assets/composables/useAssetSelecti
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { resolveAssetOutputs } from '@/platform/assets/composables/resolveAssetOutputs'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
@@ -374,8 +374,8 @@ const {
error: folderError,
execute: loadFolderAssets
} = useAsyncState(
(metadata: OutputAssetMetadata, options: { createdAt?: string } = {}) =>
resolveOutputAssetItems(metadata, options),
(asset: AssetItem) =>
resolveAssetOutputs(asset, { createdAt: asset.created_at }),
[] as AssetItem[],
{ immediate: false, resetOnExecute: true }
)
@@ -467,7 +467,7 @@ const galleryItems = computed(() => {
Object.defineProperty(resultItem, 'url', {
get() {
return asset.preview_url || ''
return asset.preview_url || getAssetUrl(asset)
},
configurable: true
})
@@ -593,12 +593,8 @@ const handleZoomClick = (asset: AssetItem) => {
const enterFolderView = async (asset: AssetItem) => {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
console.warn('Invalid output asset metadata')
return
}
const { jobId, executionTimeInSeconds } = metadata
const jobId = asset.prompt_id ?? metadata?.jobId
const executionTimeInSeconds = metadata?.executionTimeInSeconds
if (!jobId) {
console.warn('Missing required folder view data')
@@ -607,9 +603,10 @@ const enterFolderView = async (asset: AssetItem) => {
folderJobId.value = jobId
folderExecutionTime.value = executionTimeInSeconds
expectedFolderCount.value = metadata.outputCount ?? 0
const outputCount = asset.user_metadata?.outputCount
expectedFolderCount.value = typeof outputCount === 'number' ? outputCount : 0
await loadFolderAssets(0, metadata, { createdAt: asset.created_at })
await loadFolderAssets(0, asset)
if (folderError.value) {
toast.add({

View File

@@ -244,8 +244,8 @@ const adaptedAsset = computed(() => {
src:
fileKind.value === '3D'
? getAssetUrl(asset)
: asset.thumbnail_url || asset.preview_url || '',
preview_url: asset.preview_url,
: asset.thumbnail_url || asset.preview_url || getAssetUrl(asset),
preview_url: asset.preview_url || getAssetUrl(asset),
preview_id: asset.preview_id,
size: asset.size,
tags: asset.tags || [],
@@ -309,12 +309,15 @@ const handleOutputCountClick = () => {
emit('output-count-click')
}
function dragStart(e: DragEvent) {
if (!asset?.preview_url) return
if (!asset) return
const previewUrl = asset.preview_url || getAssetUrl(asset)
if (!previewUrl) return
const { dataTransfer } = e
if (!dataTransfer) return
const url = URL.parse(asset.preview_url, location.href)
const url = URL.parse(previewUrl, location.href)
if (!url) return
dataTransfer.items.add(url.toString(), 'text/uri-list')

View File

@@ -0,0 +1,24 @@
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ResolveOutputAssetItemsOptions } from '@/platform/assets/utils/outputAssetUtil'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
/**
* Resolve all output assets for a given asset.
* Cloud: uses Assets API via fetchPromptAssets.
* OSS: uses Jobs API via resolveOutputAssetItems.
*/
export async function resolveAssetOutputs(
asset: AssetItem,
options?: ResolveOutputAssetItemsOptions & { excludeParent?: boolean }
): Promise<AssetItem[]> {
if (asset.prompt_id) {
const all = await assetService.fetchPromptAssets(asset.prompt_id)
return options?.excludeParent ? all.filter((a) => a.id !== asset.id) : all
}
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) return []
return resolveOutputAssetItems(metadata, options)
}

View File

@@ -41,7 +41,9 @@ export function useMediaAssetActions() {
/**
* Internal helper to perform the API deletion for a single asset
* Handles both output assets (via history API) and input assets (via asset service)
* Cloud output assets are deleted individually via asset service.
* OSS output assets are deleted via history API (deletes entire job).
* Input assets can only be deleted in cloud environment.
* @throws Error if deletion fails or is not allowed
*/
const deleteAssetApi = async (
@@ -49,14 +51,20 @@ export function useMediaAssetActions() {
assetType: string
): Promise<void> => {
if (assetType === 'output') {
const jobId =
getOutputAssetMetadata(asset.user_metadata)?.jobId || asset.id
if (!jobId) {
throw new Error('Unable to extract job ID from asset')
if (isCloud) {
await assetService.deleteAsset(asset.id)
if (asset.prompt_id) {
assetService.invalidatePromptAssetsCache(asset.prompt_id)
}
} else {
const jobId =
getOutputAssetMetadata(asset.user_metadata)?.jobId || asset.id
if (!jobId) {
throw new Error('Unable to extract job ID from asset')
}
await api.deleteItem('history', jobId)
}
await api.deleteItem('history', jobId)
} else {
// Input assets can only be deleted in cloud environment
if (!isCloud) {
throw new Error(t('mediaAsset.deletingImportedFilesCloudOnly'))
}
@@ -100,7 +108,7 @@ export function useMediaAssetActions() {
const hasMultiOutputJobs = assets.some((a) => {
const count = getOutputAssetMetadata(a.user_metadata)?.outputCount
return typeof count === 'number' && count > 1
return (typeof count === 'number' && count > 1) || !!a.prompt_id
})
if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) {
@@ -142,19 +150,23 @@ 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 = asset.prompt_id || metadata?.jobId
if (!jobId) {
assetIds.push(asset.id)
continue
}
if (!jobIds.includes(jobId)) {
jobIds.push(jobId)
}
// Only add name filters when outputCount is unknown.
// When outputCount is set, the asset is a job-level selection
// from the gallery and the user wants all outputs for that job.
if (metadata?.jobId && asset.name && metadata.outputCount == null) {
if (!jobAssetNameFilters[metadata.jobId]) {
jobAssetNameFilters[metadata.jobId] = []
if (asset.name && metadata?.outputCount == null) {
if (!jobAssetNameFilters[jobId]) {
jobAssetNameFilters[jobId] = []
}
if (!jobAssetNameFilters[metadata.jobId].includes(asset.name)) {
jobAssetNameFilters[metadata.jobId].push(asset.name)
if (!jobAssetNameFilters[jobId].includes(asset.name)) {
jobAssetNameFilters[jobId].push(asset.name)
}
}
} else {
@@ -195,6 +207,7 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const jobId =
targetAsset.prompt_id ||
metadata?.jobId ||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)

View File

@@ -2,20 +2,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as OutputAssetUtil from '@/platform/assets/utils/outputAssetUtil'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
const mocks = vi.hoisted(() => ({
resolveOutputAssetItems: vi.fn()
resolveAssetOutputs: vi.fn()
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal<typeof OutputAssetUtil>()
return {
...actual,
resolveOutputAssetItems: mocks.resolveOutputAssetItems
}
})
vi.mock('@/platform/assets/composables/resolveAssetOutputs', () => ({
resolveAssetOutputs: mocks.resolveAssetOutputs
}))
type Deferred<T> = {
promise: Promise<T>
@@ -66,19 +61,20 @@ describe('useOutputStacks', () => {
user_metadata: undefined
})
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([childA, childB])
mocks.resolveAssetOutputs.mockResolvedValue([childA, childB])
const { assetItems, isStackExpanded, selectableAssets, toggleStack } =
useOutputStacks({ assets: ref([parent]) })
await toggleStack(parent)
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job-1' }),
{
expect(mocks.resolveAssetOutputs).toHaveBeenCalledWith(
parent,
expect.objectContaining({
createdAt: parent.created_at,
excludeOutputKey: 'node-1-outputs-parent.png'
}
excludeOutputKey: 'node-1-outputs-parent.png',
excludeParent: true
})
)
expect(isStackExpanded(parent)).toBe(true)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
@@ -105,7 +101,7 @@ describe('useOutputStacks', () => {
user_metadata: undefined
})
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([child])
mocks.resolveAssetOutputs.mockResolvedValue([child])
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
@@ -131,7 +127,7 @@ describe('useOutputStacks', () => {
await toggleStack(asset)
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
expect(mocks.resolveAssetOutputs).not.toHaveBeenCalled()
expect(isStackExpanded(asset)).toBe(false)
expect(assetItems.value).toHaveLength(1)
expect(assetItems.value[0].asset).toMatchObject(asset)
@@ -140,7 +136,7 @@ describe('useOutputStacks', () => {
it('does not expand when no children are resolved', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([])
mocks.resolveAssetOutputs.mockResolvedValue([])
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
@@ -156,9 +152,7 @@ describe('useOutputStacks', () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mocks.resolveOutputAssetItems).mockRejectedValue(
new Error('resolve failed')
)
mocks.resolveAssetOutputs.mockRejectedValue(new Error('resolve failed'))
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
@@ -181,7 +175,7 @@ describe('useOutputStacks', () => {
})
const deferred = createDeferred<AssetItem[]>()
vi.mocked(mocks.resolveOutputAssetItems).mockReturnValue(deferred.promise)
mocks.resolveAssetOutputs.mockReturnValue(deferred.promise)
const { assetItems, toggleStack } = useOutputStacks({
assets: ref([parent])
@@ -190,7 +184,7 @@ describe('useOutputStacks', () => {
const firstToggle = toggleStack(parent)
const secondToggle = toggleStack(parent)
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
expect(mocks.resolveAssetOutputs).toHaveBeenCalledTimes(1)
deferred.resolve([child])
@@ -202,4 +196,77 @@ describe('useOutputStacks', () => {
child.id
])
})
describe('cloud path (prompt_id)', () => {
it('passes excludeParent for cloud assets with prompt_id', async () => {
const parent = createAsset({
id: 'parent',
name: 'parent.png',
prompt_id: 'prompt-1',
user_metadata: undefined
})
const childA = createAsset({ id: 'child-a', name: 'a.png' })
const childB = createAsset({ id: 'child-b', name: 'b.png' })
mocks.resolveAssetOutputs.mockResolvedValue([childA, childB])
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
})
await toggleStack(parent)
expect(mocks.resolveAssetOutputs).toHaveBeenCalledWith(
parent,
expect.objectContaining({ excludeParent: true })
)
expect(isStackExpanded(parent)).toBe(true)
expect(assetItems.value.map((i) => i.asset.id)).toEqual([
'parent',
'child-a',
'child-b'
])
})
it('uses prompt_id as stack key over metadata.jobId', async () => {
const assetA = createAsset({
id: 'a',
prompt_id: 'shared-prompt',
user_metadata: { jobId: 'job-1', nodeId: '1', subfolder: '' }
})
const assetB = createAsset({
id: 'b',
prompt_id: 'different-prompt',
user_metadata: { jobId: 'job-1', nodeId: '1', subfolder: '' }
})
const child = createAsset({ id: 'child', name: 'child.png' })
mocks.resolveAssetOutputs.mockResolvedValue([child])
const { isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([assetA, assetB])
})
await toggleStack(assetA)
expect(isStackExpanded(assetA)).toBe(true)
expect(isStackExpanded(assetB)).toBe(false)
})
it('ignores asset without prompt_id or metadata', async () => {
const asset = createAsset({
id: 'no-key',
prompt_id: null,
user_metadata: undefined
})
const { toggleStack } = useOutputStacks({
assets: ref([asset])
})
await toggleStack(asset)
expect(mocks.resolveAssetOutputs).not.toHaveBeenCalled()
})
})
})

View File

@@ -3,10 +3,8 @@ import type { Ref } from 'vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getOutputKey,
resolveOutputAssetItems
} from '@/platform/assets/utils/outputAssetUtil'
import { getOutputKey } from '@/platform/assets/utils/outputAssetUtil'
import { resolveAssetOutputs } from './resolveAssetOutputs'
export type OutputStackListItem = {
key: string
@@ -55,6 +53,7 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
)
function getStackJobId(asset: AssetItem): string | null {
if (asset.prompt_id) return asset.prompt_id
const metadata = getOutputAssetMetadata(asset.user_metadata)
return metadata?.jobId ?? null
}
@@ -107,21 +106,21 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
async function resolveStackChildren(asset: AssetItem): Promise<AssetItem[]> {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
return []
}
if (!metadata && !asset.prompt_id) return []
const excludeOutputKey =
getOutputKey({
nodeId: metadata.nodeId,
subfolder: metadata.subfolder,
filename: asset.name
}) ?? undefined
const excludeOutputKey = metadata
? (getOutputKey({
nodeId: metadata.nodeId,
subfolder: metadata.subfolder,
filename: asset.name
}) ?? undefined)
: undefined
try {
return await resolveOutputAssetItems(metadata, {
return await resolveAssetOutputs(asset, {
createdAt: asset.created_at,
excludeOutputKey
excludeOutputKey,
excludeParent: true
})
} catch (error) {
console.error('Failed to resolve stack children:', error)

View File

@@ -12,6 +12,7 @@ const zAsset = z.object({
display_name: z.string().optional(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),
prompt_id: z.string().nullable().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
@@ -104,3 +105,77 @@ describe(assetService.shouldUseAssetBrowser, () => {
).toBe(false)
})
})
describe(assetService.getAssetsByJobIds, () => {
const mockFetchApi = vi.mocked(api.fetchApi)
function mockFetchApiResponse(assets: Record<string, unknown>[]) {
mockFetchApi.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ assets })
} as unknown as Response)
}
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty array for empty jobIds', async () => {
const result = await assetService.getAssetsByJobIds([])
expect(result).toEqual([])
expect(mockFetchApi).not.toHaveBeenCalled()
})
it('constructs URL with job_ids query param', async () => {
mockFetchApiResponse([])
await assetService.getAssetsByJobIds(['job-1', 'job-2'])
const url = mockFetchApi.mock.calls[0][0] as string
expect(url).toContain('job_ids=job-1%2Cjob-2')
})
it('includes offset when greater than 0', async () => {
mockFetchApiResponse([])
await assetService.getAssetsByJobIds(['job-1'], { offset: 10 })
const url = mockFetchApi.mock.calls[0][0] as string
expect(url).toContain('offset=10')
})
it('omits offset when 0', async () => {
mockFetchApiResponse([])
await assetService.getAssetsByJobIds(['job-1'], { offset: 0 })
const url = mockFetchApi.mock.calls[0][0] as string
expect(url).not.toContain('offset')
})
it('throws on non-OK response', async () => {
mockFetchApi.mockResolvedValueOnce({
ok: false,
status: 500
} as unknown as Response)
await expect(assetService.getAssetsByJobIds(['job-1'])).rejects.toThrow(
'Server returned 500'
)
})
it('returns parsed assets from response', async () => {
const assets = [
{ id: 'a1', name: 'img.png', tags: ['output'] },
{ id: 'a2', name: 'img2.png', tags: ['output'] }
]
mockFetchApiResponse(assets)
const result = await assetService.getAssetsByJobIds(['job-1'])
expect(result).toHaveLength(2)
expect(result[0].id).toBe('a1')
expect(result[1].id).toBe('a2')
})
})

View File

@@ -1,3 +1,4 @@
import QuickLRU from '@alloc/quick-lru'
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
@@ -225,6 +226,59 @@ function createAssetService() {
const data = await res.json()
return validateAssetResponse(data)
}
/**
* Gets output assets filtered by job IDs (prompt_id)
*
* @param jobIds - Array of job/prompt IDs to filter by
* @param options - Pagination options
* @returns Promise<AssetItem[]> - Assets matching the given job IDs
*/
async function getAssetsByJobIds(
jobIds: string[],
{ limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {}
): Promise<AssetItem[]> {
if (jobIds.length === 0) return []
const queryParams = new URLSearchParams({
job_ids: jobIds.join(','),
limit: limit.toString()
})
if (offset > 0) {
queryParams.set('offset', offset.toString())
}
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
const res = await api.fetchApi(url)
if (!res.ok) {
throw new Error(
`Unable to load assets for job IDs: Server returned ${res.status}`
)
}
const data = await res.json()
const response = validateAssetResponse(data)
return response?.assets ?? []
}
const promptAssetsCache = new QuickLRU<string, AssetItem[]>({ maxSize: 50 })
/**
* Fetch output assets for a prompt_id with LRU caching.
* Filters out temp assets — only returns assets tagged 'output'.
*/
async function fetchPromptAssets(promptId: string): Promise<AssetItem[]> {
const cached = promptAssetsCache.get(promptId)
if (cached) return cached
const allAssets = await getAssetsByJobIds([promptId])
const outputAssets = allAssets.filter((a) => a.tags.includes('output'))
promptAssetsCache.set(promptId, outputAssets)
return outputAssets
}
function invalidatePromptAssetsCache(promptId: string) {
promptAssetsCache.delete(promptId)
}
/**
* Gets a list of model folder keys from the asset API
*
@@ -757,6 +811,9 @@ function createAssetService() {
return {
getAssetModelFolders,
getAssetModels,
getAssetsByJobIds,
fetchPromptAssets,
invalidatePromptAssetsCache,
isAssetAPIEnabled,
isAssetBrowserEligible,
shouldUseAssetBrowser,

View File

@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetUrl } from './assetUrlUtil'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
function makeAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return { id: 'a', name: 'a.png', tags: [], ...overrides }
}
describe(getAssetUrl, () => {
it('uses hash-based URL for cloud assets with asset_hash', () => {
const asset = makeAsset({ asset_hash: 'abc123' })
const url = getAssetUrl(asset)
expect(url).toBe('/api/view?filename=abc123')
})
it('uses name+type+subfolder for OSS assets without asset_hash', () => {
const asset = makeAsset({
name: 'image.png',
tags: ['output'],
user_metadata: { subfolder: 'results' }
})
const url = getAssetUrl(asset)
expect(url).toContain('filename=image.png')
expect(url).toContain('type=output')
expect(url).toContain('subfolder=results')
})
it('omits subfolder when empty', () => {
const asset = makeAsset({
name: 'image.png',
tags: ['output'],
user_metadata: { subfolder: '' }
})
const url = getAssetUrl(asset)
expect(url).not.toContain('subfolder')
})
it('uses defaultType when asset has no tags', () => {
const asset = makeAsset({ name: 'file.png', tags: [] })
const url = getAssetUrl(asset, 'input')
expect(url).toContain('type=input')
})
it('falls back to output when no defaultType specified', () => {
const asset = makeAsset({ name: 'file.png', tags: [] })
const url = getAssetUrl(asset)
expect(url).toContain('type=output')
})
})

View File

@@ -7,21 +7,19 @@ import type { AssetItem } from '../schemas/assetSchema'
import { getAssetType } from './assetTypeUtil'
/**
* Get the download/view URL for an asset
* Constructs the proper URL with filename encoding, type, and subfolder parameters
*
* @param asset The asset to get URL for
* @param defaultType Default type if asset doesn't have tags (default: 'output')
* @returns Full URL for viewing/downloading the asset
*
* @example
* const url = getAssetUrl(asset)
* downloadFile(url, asset.name)
* Get the download/view URL for an asset.
* Cloud assets with asset_hash use `/view?filename={asset_hash}`.
* OSS assets use `/view?filename={name}&type={type}&subfolder={subfolder}`.
*/
export function getAssetUrl(
asset: AssetItem,
defaultType: 'input' | 'output' = 'output'
): string {
if (asset.asset_hash) {
const params = new URLSearchParams({ filename: asset.asset_hash })
return api.apiURL(`/view?${params}`)
}
const assetType = getAssetType(asset, defaultType)
const subfolder = asset.user_metadata?.subfolder
const params = new URLSearchParams()

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { groupAssetsByPromptId } from './groupAssetsByPromptId'
function makeAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return { id: 'a', name: 'a.png', tags: [], ...overrides }
}
describe(groupAssetsByPromptId, () => {
it('returns empty array for empty input', () => {
expect(groupAssetsByPromptId([])).toEqual([])
})
it('groups assets sharing the same prompt_id and sets outputCount', () => {
const assets = [
makeAsset({ id: '1', prompt_id: 'p1', created_at: '2024-01-01' }),
makeAsset({ id: '2', prompt_id: 'p1', created_at: '2024-01-01' }),
makeAsset({ id: '3', prompt_id: 'p1', created_at: '2024-01-01' })
]
const result = groupAssetsByPromptId(assets)
expect(result).toHaveLength(1)
expect(result[0].id).toBe('1')
expect(result[0].user_metadata?.outputCount).toBe(3)
})
it('treats assets with null prompt_id individually keyed by id', () => {
const assets = [
makeAsset({ id: 'a1', prompt_id: null, created_at: '2024-01-01' }),
makeAsset({ id: 'a2', prompt_id: null, created_at: '2024-01-02' })
]
const result = groupAssetsByPromptId(assets)
expect(result).toHaveLength(2)
expect(result.every((a) => a.user_metadata?.outputCount === 1)).toBe(true)
})
it('sets outputCount to 1 for single-asset groups', () => {
const assets = [
makeAsset({ id: '1', prompt_id: 'p1' }),
makeAsset({ id: '2', prompt_id: 'p2' })
]
const result = groupAssetsByPromptId(assets)
expect(result).toHaveLength(2)
expect(result[0].user_metadata?.outputCount).toBe(1)
expect(result[1].user_metadata?.outputCount).toBe(1)
})
it('sorts results by created_at descending', () => {
const assets = [
makeAsset({ id: '1', prompt_id: 'p1', created_at: '2024-01-01' }),
makeAsset({ id: '2', prompt_id: 'p2', created_at: '2024-03-01' }),
makeAsset({ id: '3', prompt_id: 'p3', created_at: '2024-02-01' })
]
const result = groupAssetsByPromptId(assets)
expect(result.map((a) => a.id)).toEqual(['2', '3', '1'])
})
})

View File

@@ -0,0 +1,37 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Group flat output assets by prompt_id.
* Returns one representative AssetItem per group with outputCount in user_metadata.
*/
export function groupAssetsByPromptId(assets: AssetItem[]): AssetItem[] {
const groups = new Map<string, AssetItem[]>()
for (const asset of assets) {
const key = asset.prompt_id ?? asset.id
const group = groups.get(key)
if (group) {
group.push(asset)
} else {
groups.set(key, [asset])
}
}
const grouped: AssetItem[] = []
for (const [, group] of groups) {
const representative = group[0]
grouped.push({
...representative,
user_metadata: {
...representative.user_metadata,
outputCount: group.length
}
})
}
return grouped.sort(
(a, b) =>
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime()
)
}

View File

@@ -16,7 +16,7 @@ type OutputAssetMapOptions = {
excludeOutputKey?: string
}
type ResolveOutputAssetItemsOptions = {
export type ResolveOutputAssetItemsOptions = {
createdAt?: string
excludeOutputKey?: string
}
@@ -88,6 +88,10 @@ function mapOutputsToAssetItems({
}, [])
}
/**
* Resolve all output assets for a given job via Jobs API (OSS/Desktop).
* Pure data transformation — no platform branching or API state.
*/
export async function resolveOutputAssetItems(
metadata: OutputAssetMetadata,
{ createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {}
@@ -101,7 +105,6 @@ export async function resolveOutputAssetItems(
}
}
// Reverse so the most recent outputs appear first
return mapOutputsToAssetItems({
jobId: metadata.jobId,
outputs: outputsToDisplay.toReversed(),

View File

@@ -7,8 +7,10 @@ import {
mapTaskOutputToAssetItem
} from '@/platform/assets/composables/media/assetMappers'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { PaginationOptions } from '@/platform/assets/services/assetService'
import { groupAssetsByPromptId } from '@/platform/assets/utils/groupAssetsByPromptId'
import { isCloud } from '@/platform/distribution/types'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { api } from '@/scripts/api'
@@ -114,6 +116,7 @@ export const useAssetsStore = defineStore('assets', () => {
const allHistoryItems = ref<AssetItem[]>([])
const loadedIds = shallowReactive(new Set<string>())
const loadedPromptIds = shallowReactive(new Set<string>())
const fetchInputFiles = isCloud
? fetchInputFilesFromCloud
@@ -133,11 +136,86 @@ export const useAssetsStore = defineStore('assets', () => {
})
/**
* Fetch history assets with pagination support
* @param loadMore - true for pagination (append), false for initial load (replace)
* Fetch history assets from cloud Assets API with prompt_id grouping
*/
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
// Reset state for initial load
const fetchHistoryAssetsFromCloud = async (
loadMore = false
): Promise<AssetItem[]> => {
if (!loadMore) {
historyOffset.value = 0
hasMoreHistory.value = true
allHistoryItems.value = []
loadedIds.clear()
loadedPromptIds.clear()
}
const rawAssets = await assetService.getAssetsByTag('output', false, {
limit: BATCH_SIZE,
offset: historyOffset.value
})
const newAssets = groupAssetsByPromptId(rawAssets)
if (loadMore) {
for (const asset of newAssets) {
const mergeKey = asset.prompt_id || asset.id
if (asset.prompt_id && loadedPromptIds.has(asset.prompt_id)) {
const existing = allHistoryItems.value.find(
(item) => item.prompt_id === asset.prompt_id
)
if (existing) {
const existingCount =
getOutputAssetMetadata(existing.user_metadata)?.outputCount ?? 1
const newCount =
getOutputAssetMetadata(asset.user_metadata)?.outputCount ?? 1
existing.user_metadata = {
...existing.user_metadata,
outputCount: existingCount + newCount
}
continue
}
}
if (loadedIds.has(mergeKey)) continue
loadedIds.add(mergeKey)
if (asset.prompt_id) loadedPromptIds.add(asset.prompt_id)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
allHistoryItems.value.push(asset)
} else {
allHistoryItems.value.splice(insertIndex, 0, asset)
}
}
} else {
allHistoryItems.value = newAssets
newAssets.forEach((asset) => {
loadedIds.add(asset.prompt_id || asset.id)
if (asset.prompt_id) loadedPromptIds.add(asset.prompt_id)
})
}
historyOffset.value += BATCH_SIZE
hasMoreHistory.value = rawAssets.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)
removed.forEach((item) => loadedIds.delete(item.id))
}
return allHistoryItems.value
}
/**
* Fetch history assets from Jobs API (OSS/Desktop)
*/
const fetchHistoryAssetsFromJobs = async (
loadMore = false
): Promise<AssetItem[]> => {
if (!loadMore) {
historyOffset.value = 0
hasMoreHistory.value = true
@@ -145,57 +223,49 @@ export const useAssetsStore = defineStore('assets', () => {
loadedIds.clear()
}
// Fetch from server with offset
const history = await api.getHistory(BATCH_SIZE, {
offset: historyOffset.value
})
// Convert JobListItems to AssetItems
const newAssets = mapHistoryToAssets(history)
if (loadMore) {
// Filter out duplicates and insert in sorted order
for (const asset of newAssets) {
if (loadedIds.has(asset.id)) {
continue // Skip duplicates
}
if (loadedIds.has(asset.id)) continue
loadedIds.add(asset.id)
// Find insertion index to maintain sorted order (newest first)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).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.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 fetchHistoryAssets = isCloud
? fetchHistoryAssetsFromCloud
: fetchHistoryAssetsFromJobs
const historyAssets = ref<AssetItem[]>([])
const historyLoading = ref(false)
const historyError = ref<unknown>(null)