Compare commits

...

2 Commits

Author SHA1 Message Date
dante01yoon
bc530461a2 refactor(assets): drop isCloud branch on output source
Align with the L1 stack pattern (FE-729 #12322 / FE-730 #12335 / FE-731 #12375
/ FE-732 #12417): the Generated tab now resolves to `useFlatOutputAssetsGrouped()`
unconditionally. OSS reaches the same asset-API path as cloud once BE-786 lands
and `_ASSETS_ENABLED` is no longer a gate; no transitional fallback is kept, in
line with FE-730's "no temporary isCloud fallback" stance.

FE-740
2026-05-26 21:01:02 +09:00
dante01yoon
f21a399d7e fix(assets): cloud Generated tab uses /api/assets so deleted outputs disappear
The cloud asset sidebar's "Generated" tab was sourced from `historyAssets`
(populated via `api.getHistory()`), but the asset record lifecycle is owned by
`/api/assets`. Deleting an asset via `DELETE /api/assets/{id}` left the history
job entry intact, so the sidebar kept rendering the asset with a truncated-hash
fallback name.

Switch the cloud sidebar to `useFlatOutputAssetsGrouped()` which:
- pulls from `/api/assets?include_tags=output` (the real source of truth)
- collapses rows sharing `job_id` into one card per job with `outputCount`,
  preserving the existing stack UX

Schema:
- `zAsset` gains `job_id` / `prompt_id` (both nullish), matching ingest-types
  and core OpenAPI Asset, and forward-compatible with the in-flight `prompt_id`
  -> `job_id` rename
- `OutputAssetMetadata` relaxes `nodeId` / `subfolder` to optional and the type
  guard only requires `jobId`, so flat-output rows pass downstream consumers
  (delete, download, stack expansion) without further changes

FE-740
2026-05-26 19:24:12 +09:00
6 changed files with 176 additions and 14 deletions

View File

@@ -238,6 +238,7 @@ import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContex
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useFlatOutputAssetsGrouped } from '@/platform/assets/composables/media/useFlatOutputAssetsGrouped'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -311,7 +312,7 @@ const formattedExecutionTime = computed(() => {
const toast = useToast()
const inputAssets = useAssetsApi('input')
const outputAssets = useAssetsApi('output')
const outputAssets = useFlatOutputAssetsGrouped()
// Asset selection
const {

View File

@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useFlatOutputAssetsGrouped } from './useFlatOutputAssetsGrouped'
const mediaRef: Ref<AssetItem[]> = ref([])
vi.mock('./useFlatOutputAssets', () => ({
useFlatOutputAssets: () => ({
media: mediaRef,
loading: ref(false),
error: ref(null),
hasMore: ref(false),
isLoadingMore: ref(false),
fetchMediaList: vi.fn(),
refresh: vi.fn(),
loadMore: vi.fn()
})
}))
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-id',
name: 'output.png',
tags: ['output'],
...overrides
}
}
describe('useFlatOutputAssetsGrouped', () => {
it('collapses rows with the same job_id into a single representative', () => {
mediaRef.value = [
asset({ id: 'a', name: 'out1.png', job_id: 'job-1' }),
asset({ id: 'b', name: 'out2.png', job_id: 'job-1' }),
asset({ id: 'c', name: 'out3.png', job_id: 'job-1' }),
asset({ id: 'd', name: 'solo.png', job_id: 'job-2' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['a', 'd'])
})
it('exposes the group size as user_metadata.outputCount', () => {
mediaRef.value = [
asset({ id: 'a', job_id: 'job-1' }),
asset({ id: 'b', job_id: 'job-1' }),
asset({ id: 'c', job_id: 'job-1' }),
asset({ id: 'd', job_id: 'job-2' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value[0].user_metadata?.outputCount).toBe(3)
expect(media.value[0].user_metadata?.jobId).toBe('job-1')
expect(media.value[1].user_metadata?.outputCount).toBe(1)
})
it('falls back to prompt_id when job_id is absent (legacy)', () => {
mediaRef.value = [
asset({ id: 'a', prompt_id: 'job-legacy' }),
asset({ id: 'b', prompt_id: 'job-legacy' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value).toHaveLength(1)
expect(media.value[0].user_metadata?.jobId).toBe('job-legacy')
expect(media.value[0].user_metadata?.outputCount).toBe(2)
})
it('passes through rows that have neither job_id nor prompt_id', () => {
mediaRef.value = [asset({ id: 'orphan-a' }), asset({ id: 'orphan-b' })]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['orphan-a', 'orphan-b'])
})
it('preserves the order of the first occurrence per job_id', () => {
mediaRef.value = [
asset({ id: 'a', job_id: 'job-A' }),
asset({ id: 'b', job_id: 'job-B' }),
asset({ id: 'c', job_id: 'job-A' }),
asset({ id: 'd', job_id: 'job-C' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['a', 'b', 'd'])
})
it('does not mutate the underlying assets', () => {
const original = asset({ id: 'a', job_id: 'job-1' })
mediaRef.value = [original, asset({ id: 'b', job_id: 'job-1' })]
const { media } = useFlatOutputAssetsGrouped()
void media.value
expect(original.user_metadata).toBeUndefined()
})
})

View File

@@ -0,0 +1,58 @@
import { computed } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { IAssetsProvider } from './IAssetsProvider'
import { useFlatOutputAssets } from './useFlatOutputAssets'
/**
* Cloud `/api/assets?include_tags=output` returns one row per individual output
* file. The asset sidebar's stack UX expects one card per job with an
* `outputCount` badge, so collapse rows that share a `job_id` into a single
* representative (the first occurrence — assets are returned newest-first).
*
* The siblings remain reachable through the existing stack-expand path via
* `resolveOutputAssetItems(metadata)`.
*/
export function useFlatOutputAssetsGrouped(): IAssetsProvider {
const inner = useFlatOutputAssets()
const media = computed(() => groupByJobId(inner.media.value))
return {
...inner,
media
}
}
function groupByJobId(assets: AssetItem[]): AssetItem[] {
const countsByJobId = new Map<string, number>()
for (const asset of assets) {
const jobId = asset.job_id ?? asset.prompt_id
if (!jobId) continue
countsByJobId.set(jobId, (countsByJobId.get(jobId) ?? 0) + 1)
}
const seenJobIds = new Set<string>()
const grouped: AssetItem[] = []
for (const asset of assets) {
const jobId = asset.job_id ?? asset.prompt_id ?? null
if (!jobId) {
grouped.push(asset)
continue
}
if (seenJobIds.has(jobId)) continue
seenJobIds.add(jobId)
const outputCount = countsByJobId.get(jobId) ?? 1
grouped.push({
...asset,
user_metadata: {
...asset.user_metadata,
jobId,
outputCount
}
})
}
return grouped
}

View File

@@ -56,7 +56,7 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
function getStackJobId(asset: AssetItem): string | null {
const metadata = getOutputAssetMetadata(asset.user_metadata)
return metadata?.jobId ?? null
return metadata?.jobId ?? asset.job_id ?? null
}
function isStackExpanded(asset: AssetItem): boolean {

View File

@@ -2,13 +2,14 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { ResultItemImpl } from '@/stores/queueStore'
/**
* Metadata for output assets from queue store
* Extends Record<string, unknown> for compatibility with AssetItem schema
* Metadata for output assets. Originates from the queue/history mapping but
* also surfaces on assets sourced directly from `/api/assets?include_tags=output`,
* which carry `jobId` only (no per-output `nodeId` / `subfolder`).
*/
export interface OutputAssetMetadata extends Record<string, unknown> {
jobId: string
nodeId: string | number
subfolder: string
nodeId?: string | number
subfolder?: string
executionTimeInSeconds?: number
format?: string
workflow?: ComfyWorkflowJSON
@@ -16,17 +17,11 @@ export interface OutputAssetMetadata extends Record<string, unknown> {
allOutputs?: ResultItemImpl[]
}
/**
* Type guard to check if metadata is OutputAssetMetadata
*/
function isOutputAssetMetadata(
metadata: Record<string, unknown> | undefined
): metadata is OutputAssetMetadata {
if (!metadata) return false
return (
typeof metadata.jobId === 'string' &&
(typeof metadata.nodeId === 'string' || typeof metadata.nodeId === 'number')
)
return typeof metadata.jobId === 'string'
}
/**

View File

@@ -18,7 +18,10 @@ const zAsset = z.object({
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
job_id: z.string().nullish(),
// Deprecated alias of job_id. See ingest-types Asset schema; both backends emit this during the L6 transition.
prompt_id: z.string().nullish()
})
const zAssetResponse = zListAssetsResponse