mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
2 Commits
v1.45.4
...
austin/his
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8be9d527 | ||
|
|
3d87a28460 |
@@ -6,10 +6,9 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
|
||||
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
|
||||
@@ -19,11 +18,12 @@ import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPrev
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
|
||||
useOutputHistory()
|
||||
@@ -33,28 +33,28 @@ const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
|
||||
typeformWidgetId?: string
|
||||
}>()
|
||||
|
||||
const selectedItem = ref<AssetItem>()
|
||||
const selectedItem = ref<JobListItem>()
|
||||
const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
const showSkeleton = ref(false)
|
||||
|
||||
function handleSelection(sel: OutputSelection) {
|
||||
selectedItem.value = sel.asset
|
||||
selectedItem.value = sel.job
|
||||
selectedOutput.value = sel.output
|
||||
canShowPreview.value = sel.canShowPreview
|
||||
latentPreview.value = sel.latentPreviewUrl
|
||||
showSkeleton.value = sel.showSkeleton ?? false
|
||||
}
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
function downloadJob(item?: JobListItem) {
|
||||
for (const output of allOutputs(item))
|
||||
downloadFile(output.url, output.filename)
|
||||
}
|
||||
|
||||
async function loadWorkflow(item: AssetItem | undefined) {
|
||||
async function loadWorkflow(item: JobListItem | undefined) {
|
||||
if (!item) return
|
||||
const { workflow } = await extractWorkflowFromAsset(item)
|
||||
const workflow = await extractWorkflow(item)
|
||||
if (!workflow) return
|
||||
|
||||
if (workflow.id !== app.rootGraph.id) return app.loadGraphData(workflow)
|
||||
@@ -120,7 +120,7 @@ async function rerun(e: Event) {
|
||||
label: t('linearMode.downloadAll', {
|
||||
count: allOutputs(selectedItem).length
|
||||
}),
|
||||
command: () => downloadAsset(selectedItem)
|
||||
command: () => downloadJob(selectedItem)
|
||||
},
|
||||
{ separator: true }
|
||||
]
|
||||
@@ -128,7 +128,7 @@ async function rerun(e: Event) {
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('linearMode.deleteAllAssets'),
|
||||
command: () => mediaActions.deleteAssets(selectedItem!)
|
||||
command: () => api.deleteItem('output', selectedItem!.id)
|
||||
}
|
||||
]"
|
||||
/>
|
||||
|
||||
@@ -28,12 +28,13 @@ import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewIte
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useHistoryStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
|
||||
useOutputHistory()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const historyStore = useHistoryStore()
|
||||
const queueStore = useQueueStore()
|
||||
const store = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -56,7 +57,7 @@ const hasActiveContent = computed(
|
||||
)
|
||||
|
||||
const visibleHistory = computed(() =>
|
||||
outputs.media.value.filter((a) => allOutputs(a).length > 0)
|
||||
outputs.value.filter((a) => allOutputs(a).length > 0)
|
||||
)
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
@@ -71,7 +72,7 @@ const selectableItems = computed(() => {
|
||||
itemId: item.id
|
||||
})
|
||||
}
|
||||
for (const asset of outputs.media.value) {
|
||||
for (const asset of outputs.value) {
|
||||
const outs = allOutputs(asset)
|
||||
for (let k = 0; k < outs.length; k++) {
|
||||
items.push({
|
||||
@@ -137,11 +138,11 @@ function doEmit() {
|
||||
}
|
||||
return
|
||||
}
|
||||
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
|
||||
const output = asset ? allOutputs(asset)[sel.key] : undefined
|
||||
const isFirst = outputs.media.value[0]?.id === sel.assetId
|
||||
const job = outputs.value.find((a) => a.id === sel.assetId)
|
||||
const output = job ? allOutputs(job)[sel.key] : undefined
|
||||
const isFirst = outputs.value[0]?.id === sel.assetId
|
||||
emit('updateSelection', {
|
||||
asset,
|
||||
job,
|
||||
output,
|
||||
canShowPreview: isFirst
|
||||
})
|
||||
@@ -170,7 +171,7 @@ watch(
|
||||
|
||||
// Keep history selection stable on media changes
|
||||
watch(
|
||||
() => outputs.media.value,
|
||||
() => outputs.value,
|
||||
(newAssets, oldAssets) => {
|
||||
if (
|
||||
newAssets.length === oldAssets.length ||
|
||||
@@ -219,8 +220,8 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
||||
canLoadMore: () => outputs.hasMore.value
|
||||
useInfiniteScroll(outputsRef, historyStore.loadMoreHistory, {
|
||||
canLoadMore: () => historyStore.hasMoreHistory
|
||||
})
|
||||
|
||||
function navigateToAdjacent(direction: 1 | -1) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export interface InProgressItem {
|
||||
@@ -10,7 +10,7 @@ export interface InProgressItem {
|
||||
}
|
||||
|
||||
export interface OutputSelection {
|
||||
asset?: AssetItem
|
||||
job?: JobListItem
|
||||
output?: ResultItemImpl
|
||||
canShowPreview: boolean
|
||||
latentPreviewUrl?: string
|
||||
|
||||
@@ -2,13 +2,13 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const mediaRef = ref<AssetItem[]>([])
|
||||
const mediaRef = ref<JobListItem[]>([])
|
||||
const pendingResolveRef = ref(new Set<string>())
|
||||
const inProgressItemsRef = ref<InProgressItem[]>([])
|
||||
const activeWorkflowInProgressItemsRef = ref<InProgressItem[]>([])
|
||||
@@ -115,25 +115,29 @@ vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
function makeAsset(
|
||||
function makeJob(
|
||||
id: string,
|
||||
jobId: string,
|
||||
opts?: { allOutputs?: ResultItemImpl[]; outputCount?: number }
|
||||
): AssetItem {
|
||||
): JobListItem {
|
||||
return {
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
tags: [],
|
||||
preview_url: `/view?filename=${id}.png`,
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
...(opts?.allOutputs ? { allOutputs: opts.allOutputs } : {}),
|
||||
...(opts?.outputCount !== undefined
|
||||
? { outputCount: opts.outputCount }
|
||||
: {})
|
||||
}
|
||||
status: 'completed',
|
||||
create_time: 0,
|
||||
//name: `${id}.png`,
|
||||
//tags: [],
|
||||
output_count: opts?.outputCount,
|
||||
outputs: opts?.allOutputs && unflatResults(opts.allOutputs),
|
||||
priority: 0
|
||||
//preview_url: `/view?filename=${id}.png`,
|
||||
//user_metadata: {
|
||||
// jobId,
|
||||
// nodeId: '1',
|
||||
// subfolder: '',
|
||||
// ...(opts?.allOutputs ? { allOutputs: opts.allOutputs } : {}),
|
||||
// ...(opts?.outputCount !== undefined
|
||||
// ? { outputCount: opts.outputCount }
|
||||
// : {})
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +151,18 @@ function makeResult(filename: string, nodeId: string = '1'): ResultItemImpl {
|
||||
})
|
||||
}
|
||||
|
||||
function unflatResults(results: ResultItemImpl[]) {
|
||||
const ret: Record<string, Record<string, ResultItemImpl[]>> = {}
|
||||
for (const result of results) {
|
||||
ret[result.nodeId] ??= {}
|
||||
const nodeOutputs = ret[result.nodeId]
|
||||
|
||||
nodeOutputs[result.mediaType] ??= []
|
||||
nodeOutputs[result.mediaType].push(result)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
describe(useOutputHistory, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -172,22 +188,22 @@ describe(useOutputHistory, () => {
|
||||
['job-1', 'workflows/test.json'],
|
||||
['job-2', 'workflows/other.json']
|
||||
])
|
||||
mediaRef.value = [makeAsset('a1', 'job-1'), makeAsset('a2', 'job-2')]
|
||||
mediaRef.value = [makeJob('job-1'), makeJob('job-2')]
|
||||
|
||||
const { outputs } = useOutputHistory()
|
||||
|
||||
expect(outputs.media.value).toHaveLength(1)
|
||||
expect(outputs.media.value[0].id).toBe('a1')
|
||||
expect(outputs.value).toHaveLength(1)
|
||||
expect(outputs.value[0].id).toBe('a1')
|
||||
})
|
||||
|
||||
it('returns empty when no workflow is active', () => {
|
||||
activeWorkflowPathRef.value = ''
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
mediaRef.value = [makeAsset('a1', 'job-1')]
|
||||
mediaRef.value = [makeJob('job-1')]
|
||||
|
||||
const { outputs } = useOutputHistory()
|
||||
|
||||
expect(outputs.media.value).toHaveLength(0)
|
||||
expect(outputs.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('updates when active workflow changes', async () => {
|
||||
@@ -195,19 +211,19 @@ describe(useOutputHistory, () => {
|
||||
['job-1', 'workflows/a.json'],
|
||||
['job-2', 'workflows/b.json']
|
||||
])
|
||||
mediaRef.value = [makeAsset('a1', 'job-1'), makeAsset('a2', 'job-2')]
|
||||
mediaRef.value = [makeJob('job-1'), makeJob('job-2')]
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/a.json'
|
||||
const { outputs } = useOutputHistory()
|
||||
|
||||
expect(outputs.media.value).toHaveLength(1)
|
||||
expect(outputs.media.value[0].id).toBe('a1')
|
||||
expect(outputs.value).toHaveLength(1)
|
||||
expect(outputs.value[0].id).toBe('a1')
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/b.json'
|
||||
await nextTick()
|
||||
|
||||
expect(outputs.media.value).toHaveLength(1)
|
||||
expect(outputs.media.value[0].id).toBe('a2')
|
||||
expect(outputs.value).toHaveLength(1)
|
||||
expect(outputs.value[0].id).toBe('a2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -222,7 +238,7 @@ describe(useOutputHistory, () => {
|
||||
it('returns outputs from metadata allOutputs when count matches', () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
const results = [makeResult('a.png'), makeResult('b.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
const asset = makeJob('job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 2
|
||||
})
|
||||
@@ -242,7 +258,7 @@ describe(useOutputHistory, () => {
|
||||
makeResult('b.png', '2'),
|
||||
makeResult('c.png', '3')
|
||||
]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
const asset = makeJob('job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 3
|
||||
})
|
||||
@@ -259,7 +275,7 @@ describe(useOutputHistory, () => {
|
||||
|
||||
it('returns empty when no output nodes are selected', () => {
|
||||
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
const asset = makeJob('job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 2
|
||||
})
|
||||
@@ -272,7 +288,7 @@ describe(useOutputHistory, () => {
|
||||
|
||||
it('returns consistent filtered outputs across repeated calls', () => {
|
||||
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
const asset = makeJob('job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 2
|
||||
})
|
||||
@@ -306,7 +322,7 @@ describe(useOutputHistory, () => {
|
||||
output: makeResult('b.png')
|
||||
}
|
||||
]
|
||||
const asset = makeAsset('a1', 'job-1')
|
||||
const asset = makeJob('job-1')
|
||||
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const outputs = allOutputs(asset)
|
||||
@@ -329,7 +345,7 @@ describe(useOutputHistory, () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
const asset = makeAsset('a1', 'job-1')
|
||||
const asset = makeJob('job-1')
|
||||
|
||||
const { allOutputs } = useOutputHistory()
|
||||
|
||||
@@ -348,7 +364,7 @@ describe(useOutputHistory, () => {
|
||||
it('resolves pending jobs when history outputs load', async () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
const results = [makeResult('a.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
const asset = makeJob('job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 1
|
||||
})
|
||||
@@ -367,7 +383,7 @@ describe(useOutputHistory, () => {
|
||||
it('does not select first history when a selection exists', async () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
const results = [makeResult('a.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
const asset = makeJob('job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 1
|
||||
})
|
||||
@@ -397,7 +413,7 @@ describe(useOutputHistory, () => {
|
||||
describe('selectFirstHistory', () => {
|
||||
it('selects first media item', () => {
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
mediaRef.value = [makeAsset('a1', 'job-1')]
|
||||
mediaRef.value = [makeJob('job-1')]
|
||||
|
||||
const { selectFirstHistory } = useOutputHistory()
|
||||
selectFirstHistory()
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { computed, watchEffect } from 'vue'
|
||||
|
||||
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import {
|
||||
TaskItemImpl,
|
||||
useHistoryStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function useOutputHistory(): {
|
||||
outputs: IAssetsProvider
|
||||
allOutputs: (item?: AssetItem) => ResultItemImpl[]
|
||||
outputs: ComputedRef<JobListItem[]>
|
||||
allOutputs: (item?: JobListItem) => readonly ResultItemImpl[]
|
||||
selectFirstHistory: () => void
|
||||
mayBeActiveWorkflowPending: ComputedRef<boolean>
|
||||
isWorkflowActive: ComputedRef<boolean>
|
||||
cancelActiveWorkflowJobs: () => Promise<void>
|
||||
} {
|
||||
const backingOutputs = useMediaAssets('output')
|
||||
void backingOutputs.fetchMediaList()
|
||||
const linearStore = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const queueStore = useQueueStore()
|
||||
const historyStore = useHistoryStore()
|
||||
|
||||
function matchesActiveWorkflow(task: { jobId: string | number }): boolean {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
@@ -60,7 +58,9 @@ export function useOutputHistory(): {
|
||||
hasActiveWorkflowJobs()
|
||||
)
|
||||
|
||||
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
|
||||
function filterByOutputNodes(
|
||||
items: readonly ResultItemImpl[]
|
||||
): readonly ResultItemImpl[] {
|
||||
const nodeIds = appModeStore.selectedOutputs
|
||||
if (!nodeIds.length) return []
|
||||
return items.filter((r) =>
|
||||
@@ -74,76 +74,45 @@ export function useOutputHistory(): {
|
||||
|
||||
const pathMap = executionStore.jobIdToSessionWorkflowPath
|
||||
|
||||
return backingOutputs.media.value.filter((asset) => {
|
||||
const m = getOutputAssetMetadata(asset?.user_metadata)
|
||||
return m ? pathMap.get(m.jobId) === path : false
|
||||
})
|
||||
return historyStore.historyItems.filter(
|
||||
(item) => pathMap.get(item.id) === path
|
||||
)
|
||||
})
|
||||
|
||||
const outputs: IAssetsProvider = {
|
||||
...backingOutputs,
|
||||
media: sessionMedia,
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false),
|
||||
loadMore: async () => {}
|
||||
}
|
||||
|
||||
const resolvedCache = linearStore.resolvedOutputsCache
|
||||
const asyncRefs = new Map<
|
||||
string,
|
||||
ReturnType<typeof useAsyncState<ResultItemImpl[]>>['state']
|
||||
ReturnType<typeof useAsyncState<readonly ResultItemImpl[]>>['state']
|
||||
>()
|
||||
|
||||
function allOutputs(item?: AssetItem): ResultItemImpl[] {
|
||||
function allOutputs(item?: JobListItem): readonly ResultItemImpl[] {
|
||||
if (!item?.id) return []
|
||||
|
||||
const cached = resolvedCache.get(item.id)
|
||||
if (cached) return filterByOutputNodes(cached)
|
||||
|
||||
const user_metadata = getOutputAssetMetadata(item.user_metadata)
|
||||
if (!user_metadata) return []
|
||||
|
||||
/*FIXME
|
||||
// For recently completed jobs still pending resolve, derive order from
|
||||
// the in-progress items which are in correct execution order.
|
||||
if (linearStore.pendingResolve.has(user_metadata.jobId)) {
|
||||
if (linearStore.pendingResolve.has(item.Id)) {
|
||||
const ordered = linearStore.inProgressItems
|
||||
.filter((i) => i.jobId === user_metadata.jobId && i.output)
|
||||
.filter((i) => i.id === item.id && i.output)
|
||||
.map((i) => i.output!)
|
||||
if (ordered.length > 0) {
|
||||
resolvedCache.set(item.id, ordered)
|
||||
return filterByOutputNodes(ordered)
|
||||
}
|
||||
}
|
||||
|
||||
// Use metadata when all outputs are present. The /jobs list endpoint
|
||||
// only returns preview_output (single item), so outputCount may exceed
|
||||
// allOutputs.length for multi-output jobs.
|
||||
if (
|
||||
user_metadata.allOutputs?.length &&
|
||||
(!user_metadata.outputCount ||
|
||||
user_metadata.outputCount <= user_metadata.allOutputs.length) &&
|
||||
item.preview_url
|
||||
) {
|
||||
const reversed = user_metadata.allOutputs.toReversed()
|
||||
resolvedCache.set(item.id, reversed)
|
||||
return filterByOutputNodes(reversed)
|
||||
}
|
||||
}*/
|
||||
|
||||
// Async fallback for multi-output jobs — fetch full /jobs/{id} detail.
|
||||
// This can be hit if the user executes the job then switches tabs.
|
||||
const existing = asyncRefs.get(item.id)
|
||||
if (existing) return filterByOutputNodes(existing.value)
|
||||
|
||||
const itemId = item.id
|
||||
const outputRef = useAsyncState(
|
||||
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
||||
if (!jobDetail?.outputs) return []
|
||||
const results = Object.entries(jobDetail.outputs)
|
||||
.flatMap(flattenNodeOutput)
|
||||
.toReversed()
|
||||
resolvedCache.set(itemId, results)
|
||||
return results
|
||||
}),
|
||||
new TaskItemImpl(item)
|
||||
.loadFullOutputs()
|
||||
.then((item) => item.calculateFlatOutputs()),
|
||||
[]
|
||||
).state
|
||||
asyncRefs.set(item.id, outputRef)
|
||||
@@ -151,7 +120,7 @@ export function useOutputHistory(): {
|
||||
}
|
||||
|
||||
function selectFirstHistory() {
|
||||
const first = outputs.media.value[0]
|
||||
const first = historyStore.historyItems[0]
|
||||
if (first) {
|
||||
linearStore.selectAsLatest(`history:${first.id}:0`)
|
||||
} else {
|
||||
@@ -163,12 +132,9 @@ export function useOutputHistory(): {
|
||||
watchEffect(() => {
|
||||
if (linearStore.pendingResolve.size === 0) return
|
||||
for (const jobId of linearStore.pendingResolve) {
|
||||
const asset = outputs.media.value.find((a) => {
|
||||
const m = getOutputAssetMetadata(a?.user_metadata)
|
||||
return m?.jobId === jobId
|
||||
})
|
||||
if (!asset) continue
|
||||
const loaded = allOutputs(asset).length > 0
|
||||
const job = historyStore.historyItems.find((j) => j.id === jobId)
|
||||
if (!job) continue
|
||||
const loaded = allOutputs(job).length > 0
|
||||
if (loaded) {
|
||||
linearStore.resolveIfReady(jobId, true)
|
||||
if (!linearStore.selectedId) selectFirstHistory()
|
||||
@@ -194,7 +160,7 @@ export function useOutputHistory(): {
|
||||
}
|
||||
|
||||
return {
|
||||
outputs,
|
||||
outputs: sessionMedia,
|
||||
allOutputs,
|
||||
selectFirstHistory,
|
||||
mayBeActiveWorkflowPending,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAsyncState, whenever } from '@vueuse/core'
|
||||
import { difference } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, shallowReactive } from 'vue'
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
@@ -13,7 +13,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { TaskItemImpl } from './queueStore'
|
||||
import { TaskItemImpl, useHistoryStore } from './queueStore'
|
||||
import { useAssetDownloadStore } from './assetDownloadStore'
|
||||
import { useModelToNodeStore } from './modelToNodeStore'
|
||||
|
||||
@@ -84,12 +84,16 @@ function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
const historyStore = useHistoryStore()
|
||||
|
||||
const { isLoadingMore, hasMoreHistory, historyError } =
|
||||
storeToRefs(historyStore)
|
||||
const historyAssets = computed(() =>
|
||||
mapHistoryToAssets(historyStore.historyItems)
|
||||
)
|
||||
|
||||
// Track assets currently being deleted (for loading overlay)
|
||||
const deletingAssetIds = shallowReactive(new Set<string>())
|
||||
@@ -106,15 +110,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
return deletingAssetIds.has(assetId)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -132,120 +127,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
}
|
||||
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 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
|
||||
@@ -726,7 +607,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
inputAssets,
|
||||
historyAssets,
|
||||
inputLoading,
|
||||
historyLoading,
|
||||
historyLoading: isLoadingMore,
|
||||
inputError,
|
||||
historyError,
|
||||
hasMoreHistory,
|
||||
@@ -739,8 +620,8 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
// Actions
|
||||
updateInputs,
|
||||
updateHistory,
|
||||
loadMoreHistory,
|
||||
updateHistory: historyStore.updateHistory,
|
||||
loadMoreHistory: historyStore.loadMoreHistory,
|
||||
|
||||
// Input mapping helpers
|
||||
inputAssetsByFilename,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
shallowRef,
|
||||
toRaw,
|
||||
toValue,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type {
|
||||
@@ -474,6 +482,76 @@ export class TaskItemImpl {
|
||||
)
|
||||
}
|
||||
}
|
||||
export const useHistoryStore = defineStore('history', () => {
|
||||
const BATCH_SIZE = 200
|
||||
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
|
||||
|
||||
let offset = 0
|
||||
const hasMoreHistory = ref(true)
|
||||
const isLoadingMore = ref(false)
|
||||
const historyItems = ref<JobListItem[]>([])
|
||||
const historyError = ref<unknown>(null)
|
||||
|
||||
const loadedIds = new Set<string>()
|
||||
|
||||
const fetchHistory = async (): Promise<JobListItem[]> => {
|
||||
const history = await api.getHistory(BATCH_SIZE, { offset })
|
||||
const newHistory = history.filter((item) => !loadedIds.has(item.id))
|
||||
|
||||
historyItems.value.push(...newHistory)
|
||||
historyItems.value.sort((a, b) => a.create_time - b.create_time)
|
||||
newHistory.forEach((item) => loadedIds.add(item.id))
|
||||
|
||||
offset += BATCH_SIZE
|
||||
hasMoreHistory.value = history.length === BATCH_SIZE
|
||||
|
||||
if (historyItems.value.length > MAX_HISTORY_ITEMS) {
|
||||
const removed = historyItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
historyItems.value = historyItems.value.slice(0, MAX_HISTORY_ITEMS)
|
||||
removed.forEach((item) => loadedIds.delete(item.id))
|
||||
}
|
||||
|
||||
return historyItems.value
|
||||
}
|
||||
|
||||
const updateHistory = async () => {
|
||||
offset = 0
|
||||
hasMoreHistory.value = true
|
||||
historyItems.value = []
|
||||
loadedIds.clear()
|
||||
await loadMoreHistory()
|
||||
}
|
||||
|
||||
const loadMoreHistory = async () => {
|
||||
if (!hasMoreHistory.value || isLoadingMore.value) return
|
||||
if (isLoadingMore.value) {
|
||||
await new Promise((r) => watch(isLoadingMore, r, { once: true }))
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingMore.value = true
|
||||
historyError.value = null
|
||||
try {
|
||||
await fetchHistory()
|
||||
} catch (err) {
|
||||
console.error('Error loading more history:', err)
|
||||
historyError.value = err
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void loadMoreHistory()
|
||||
|
||||
return {
|
||||
hasMoreHistory,
|
||||
historyError,
|
||||
historyItems,
|
||||
isLoadingMore,
|
||||
loadMoreHistory,
|
||||
updateHistory
|
||||
}
|
||||
})
|
||||
|
||||
export const useQueueStore = defineStore('queue', () => {
|
||||
// Use shallowRef because TaskItemImpl instances are immutable and arrays are
|
||||
@@ -485,6 +563,15 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const maxHistoryItems = ref(64)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const historyStore = useHistoryStore()
|
||||
//TODO: Fix tests so this can be a computed
|
||||
watchEffect(
|
||||
() =>
|
||||
(historyTasks.value = historyStore.historyItems
|
||||
.slice(0, toValue(maxHistoryItems))
|
||||
.map((job) => new TaskItemImpl(job)))
|
||||
)
|
||||
|
||||
// Single-flight coalescing: at most one fetch in flight at a time.
|
||||
// If update() is called while a fetch is running, the call is coalesced
|
||||
// and a single re-fetch fires after the current one completes.
|
||||
@@ -525,17 +612,15 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
dirty = false
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [queue, history] = await Promise.all([
|
||||
const [queue] = await Promise.all([
|
||||
api.getQueue(),
|
||||
api.getHistory(maxHistoryItems.value)
|
||||
historyStore.updateHistory()
|
||||
])
|
||||
|
||||
// API returns pre-sorted data (sort_by=create_time&order=desc)
|
||||
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
|
||||
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
|
||||
|
||||
const currentHistory = toValue(historyTasks)
|
||||
|
||||
const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
|
||||
const executionStore = useExecutionStore()
|
||||
appearedTasks.forEach((task) => {
|
||||
@@ -557,36 +642,6 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
])
|
||||
executionStore.reconcileInitializingJobs(activeJobIds)
|
||||
}
|
||||
|
||||
// Sort by create_time descending and limit to maxItems
|
||||
const sortedHistory = [...history]
|
||||
.sort((a, b) => b.create_time - a.create_time)
|
||||
.slice(0, toValue(maxHistoryItems))
|
||||
|
||||
// Reuse existing TaskItemImpl instances or create new
|
||||
// Must recreate if outputs_count changed (e.g., API started returning it)
|
||||
const existingByJobId = new Map(
|
||||
currentHistory.map((impl) => [impl.jobId, impl])
|
||||
)
|
||||
|
||||
const nextHistoryTasks = sortedHistory.map((job) => {
|
||||
const existing = existingByJobId.get(job.id)
|
||||
if (!existing) return new TaskItemImpl(job)
|
||||
// Recreate if outputs_count changed to ensure lazy loading works
|
||||
if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
|
||||
return new TaskItemImpl(job)
|
||||
}
|
||||
return existing
|
||||
})
|
||||
|
||||
const isHistoryUnchanged =
|
||||
nextHistoryTasks.length === currentHistory.length &&
|
||||
nextHistoryTasks.every((task, index) => task === currentHistory[index])
|
||||
|
||||
if (!isHistoryUnchanged) {
|
||||
historyTasks.value = nextHistoryTasks
|
||||
}
|
||||
hasFetchedHistorySnapshot.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
inFlight = false
|
||||
|
||||
@@ -76,7 +76,6 @@ import { app } from '@/scripts/app'
|
||||
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -106,7 +105,6 @@ const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const queueStore = useQueueStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const { isBuilderMode } = useAppMode()
|
||||
@@ -226,25 +224,14 @@ void useBottomPanelStore().registerCoreBottomPanelTabs()
|
||||
|
||||
useQueuePolling()
|
||||
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
|
||||
queuePendingTaskCountStore.update(e)
|
||||
await queueStore.update()
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const onExecutionSuccess = async () => {
|
||||
await queueStore.update()
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
Reference in New Issue
Block a user