mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 13:10:24 +00:00
App mode output feed to only show current session results for outputs defined in the app (#9307)
## Summary Updates app mode to only show images from: - the workflow that generated the image - in the current session - for the outputs selected in the builder ## Changes - **What**: - adds new mapping of jobid -> workflow path [cant use id here as it is not guaranteed unique], capped at 4k entries - fix bug where executing a workflow then quickly switching tabs associated incorrect workflow - add missing output history tests ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9307-App-mode-output-feed-to-only-show-current-session-results-for-outputs-defined-in-the-app-3156d73d36508142b4bbca3f938fc5c2) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -1,17 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useInfiniteScroll } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { ListboxContent, ListboxItem, ListboxRoot } from 'reka-ui'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
toValue,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { computed, nextTick, useTemplateRef, watch, watchEffect } from 'vue'
|
||||
|
||||
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
|
||||
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
@@ -24,7 +16,7 @@ import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHist
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { outputs, allOutputs } = useOutputHistory()
|
||||
const { outputs, allOutputs, selectFirstHistory } = useOutputHistory()
|
||||
const queueStore = useQueueStore()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
@@ -45,15 +37,17 @@ const itemClass = cn(
|
||||
'data-[state=checked]:border-interface-panel-job-progress-border'
|
||||
)
|
||||
|
||||
const hasActiveContent = computed(() => store.inProgressItems.length > 0)
|
||||
const hasActiveContent = computed(
|
||||
() => store.activeWorkflowInProgressItems.length > 0
|
||||
)
|
||||
|
||||
const visibleHistory = computed(() =>
|
||||
outputs.media.value.filter((a) => toValue(allOutputs(a)).length > 0)
|
||||
outputs.media.value.filter((a) => allOutputs(a).length > 0)
|
||||
)
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
const items: SelectionValue[] = []
|
||||
for (const item of store.inProgressItems) {
|
||||
for (const item of store.activeWorkflowInProgressItems) {
|
||||
items.push({
|
||||
id: `slot:${item.id}`,
|
||||
kind: 'inProgress',
|
||||
@@ -61,7 +55,7 @@ const selectableItems = computed(() => {
|
||||
})
|
||||
}
|
||||
for (const asset of outputs.media.value) {
|
||||
const outs = toValue(allOutputs(asset))
|
||||
const outs = allOutputs(asset)
|
||||
for (let k = 0; k < outs.length; k++) {
|
||||
items.push({
|
||||
id: `history:${asset.id}:${k}`,
|
||||
@@ -95,7 +89,9 @@ function doEmit() {
|
||||
return
|
||||
}
|
||||
if (sel.kind === 'inProgress') {
|
||||
const item = store.inProgressItems.find((i) => i.id === sel.itemId)
|
||||
const item = store.activeWorkflowInProgressItems.find(
|
||||
(i) => i.id === sel.itemId
|
||||
)
|
||||
if (!item || item.state === 'skeleton') {
|
||||
emit('updateSelection', { canShowPreview: true })
|
||||
} else if (item.state === 'latent') {
|
||||
@@ -112,7 +108,7 @@ function doEmit() {
|
||||
return
|
||||
}
|
||||
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
|
||||
const output = asset ? toValue(allOutputs(asset))[sel.key] : undefined
|
||||
const output = asset ? allOutputs(asset)[sel.key] : undefined
|
||||
const isFirst = outputs.media.value[0]?.id === sel.assetId
|
||||
emit('updateSelection', {
|
||||
asset,
|
||||
@@ -123,24 +119,6 @@ function doEmit() {
|
||||
|
||||
watchEffect(doEmit)
|
||||
|
||||
// Resolve in-progress items only when history outputs are loaded.
|
||||
// Using watchEffect so it re-runs when allOutputs refs resolve (async).
|
||||
watchEffect(() => {
|
||||
if (store.pendingResolve.size === 0) return
|
||||
for (const jobId of store.pendingResolve) {
|
||||
const asset = outputs.media.value.find((a) => {
|
||||
const m = getOutputAssetMetadata(a?.user_metadata)
|
||||
return m?.jobId === jobId
|
||||
})
|
||||
if (!asset) continue
|
||||
const loaded = toValue(allOutputs(asset)).length > 0
|
||||
if (loaded) {
|
||||
store.resolveIfReady(jobId, true)
|
||||
if (!store.selectedId) selectFirstHistory()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Keep history selection stable on media changes
|
||||
watch(
|
||||
() => outputs.media.value,
|
||||
@@ -169,19 +147,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function selectFirstHistory() {
|
||||
const first = outputs.media.value[0]
|
||||
if (first) {
|
||||
store.selectAsLatest(`history:${first.id}:0`)
|
||||
} else {
|
||||
store.selectAsLatest(null)
|
||||
}
|
||||
}
|
||||
|
||||
const outputsRef = useTemplateRef('outputsRef')
|
||||
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
||||
canLoadMore: () => outputs.hasMore.value
|
||||
})
|
||||
|
||||
// Reka UI's ListboxContent stops propagation on ALL Enter keydown events,
|
||||
// which blocks modifier+Enter (Ctrl+Enter = run workflow) from reaching
|
||||
@@ -296,7 +262,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
</div>
|
||||
|
||||
<ListboxItem
|
||||
v-for="item in store.inProgressItems"
|
||||
v-for="item in store.activeWorkflowInProgressItems"
|
||||
:key="`${item.id}-${item.state}`"
|
||||
:value="{
|
||||
id: `slot:${item.id}`,
|
||||
@@ -323,7 +289,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
/>
|
||||
<ListboxItem
|
||||
v-for="(output, key) in toValue(allOutputs(asset))"
|
||||
v-for="(output, key) in allOutputs(asset)"
|
||||
:key
|
||||
:value="{
|
||||
id: `history:${asset.id}:${key}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
@@ -9,6 +9,9 @@ import { ResultItemImpl } from '@/stores/queueStore'
|
||||
const activeJobIdRef = ref<string | null>(null)
|
||||
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
|
||||
const isAppModeRef = ref(true)
|
||||
const activeWorkflowPathRef = ref<string>('workflows/test-workflow.json')
|
||||
const jobIdToWorkflowPathRef = ref(new Map<string, string>())
|
||||
const selectedOutputsRef = ref<string[]>([])
|
||||
|
||||
const { apiTarget } = vi.hoisted(() => ({
|
||||
apiTarget: new EventTarget()
|
||||
@@ -20,10 +23,29 @@ vi.mock('@/composables/useAppMode', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({
|
||||
get selectedOutputs() {
|
||||
return selectedOutputsRef.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
get activeJobId() {
|
||||
return activeJobIdRef.value
|
||||
},
|
||||
get jobIdToSessionWorkflowPath() {
|
||||
return jobIdToWorkflowPathRef.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return { path: activeWorkflowPathRef.value }
|
||||
}
|
||||
})
|
||||
}))
|
||||
@@ -59,6 +81,12 @@ vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
function setJobWorkflowPath(jobId: string, path: string) {
|
||||
const next = new Map(jobIdToWorkflowPathRef.value)
|
||||
next.set(jobId, path)
|
||||
jobIdToWorkflowPathRef.value = next
|
||||
}
|
||||
|
||||
function makeExecutedDetail(
|
||||
promptId: string,
|
||||
images: Array<Record<string, string>> = [
|
||||
@@ -80,11 +108,9 @@ describe('linearOutputStore', () => {
|
||||
activeJobIdRef.value = null
|
||||
previewsRef.value = {}
|
||||
isAppModeRef.value = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
activeJobIdRef.value = null
|
||||
previewsRef.value = {}
|
||||
activeWorkflowPathRef.value = 'workflows/test-workflow.json'
|
||||
jobIdToWorkflowPathRef.value = new Map()
|
||||
selectedOutputsRef.value = []
|
||||
})
|
||||
|
||||
it('creates a skeleton item when a job starts', () => {
|
||||
@@ -613,10 +639,105 @@ describe('linearOutputStore', () => {
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
expect(store.selectedId).toBeNull()
|
||||
expect(store.trackedJobId).toBeNull()
|
||||
expect(store.pendingResolve.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show in-progress items from another workflow', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// Job-1 submitted from workflow-a
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// User switches to workflow-b: job-1 should NOT appear
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
|
||||
// Back on workflow-a: job-1 should appear
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
||||
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-1')
|
||||
})
|
||||
|
||||
it('uses executionStore path map for workflow scoping', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// Simulate storeJob populating executionStore.jobIdToSessionWorkflowPath
|
||||
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/app-a.json')
|
||||
|
||||
// User switches to workflow-b before execution starts
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
|
||||
store.onJobStart('job-1')
|
||||
store.onJobStart('job-2')
|
||||
|
||||
// On workflow-b: neither job should appear
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
|
||||
// On workflow-a: both jobs should appear
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('scopes in-progress items per workflow with concurrent jobs', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// Job-1 on workflow-a (dog)
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||
store.onJobStart('job-1')
|
||||
store.onLatentPreview('job-1', 'blob:dog')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// User switches to workflow-b, runs job-2
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
setJobWorkflowPath('job-2', 'workflows/app-b.json')
|
||||
|
||||
// Job-1 finishes, job-2 starts
|
||||
store.onJobComplete('job-1')
|
||||
store.onJobStart('job-2')
|
||||
store.onLatentPreview('job-2', 'blob:landscape')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// On workflow-b: should only see job-2 (landscape), NOT job-1 (dog)
|
||||
const items = store.activeWorkflowInProgressItems
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].jobId).toBe('job-2')
|
||||
expect(items[0].latentPreviewUrl).toBe('blob:landscape')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('skips output items for nodes not in selectedOutputs', () => {
|
||||
selectedOutputsRef.value = ['2']
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Node 1 executes — not in selectedOutputs, should be skipped
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
||||
|
||||
// Skeleton should still be there (not consumed by non-output node)
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(0)
|
||||
|
||||
// Node 2 executes — in selectedOutputs, should create image item
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail(
|
||||
'job-1',
|
||||
[{ filename: 'out.png', subfolder: '', type: 'output' }],
|
||||
'2'
|
||||
)
|
||||
)
|
||||
|
||||
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
|
||||
expect(imageItems).toHaveLength(1)
|
||||
expect(imageItems[0].output?.nodeId).toBe('2')
|
||||
})
|
||||
|
||||
it('ignores execution events when not in app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
|
||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const { isAppMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const jobPreviewStore = useJobPreviewStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const inProgressItems = ref<InProgressItem[]>([])
|
||||
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
|
||||
const selectedId = ref<string | null>(null)
|
||||
const isFollowing = ref(true)
|
||||
const trackedJobId = ref<string | null>(null)
|
||||
const pendingResolve = ref(new Set<string>())
|
||||
const executedNodeIds = new Set<string>()
|
||||
|
||||
const activeWorkflowInProgressItems = computed(() => {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return []
|
||||
const all = inProgressItems.value
|
||||
return all.filter(
|
||||
(i) => executionStore.jobIdToSessionWorkflowPath.get(i.jobId) === path
|
||||
)
|
||||
})
|
||||
|
||||
let nextSeq = 0
|
||||
|
||||
function makeItemId(jobId: string): string {
|
||||
@@ -42,6 +57,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
|
||||
function onJobStart(jobId: string) {
|
||||
executedNodeIds.clear()
|
||||
|
||||
const item: InProgressItem = {
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
@@ -101,6 +117,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
||||
if (newOutputs.length === 0) return
|
||||
|
||||
// Skip output items for nodes not flagged as output nodes
|
||||
const outputNodeIds = appModeStore.selectedOutputs
|
||||
if (
|
||||
outputNodeIds.length > 0 &&
|
||||
!outputNodeIds.some((id) => String(id) === String(nodeId))
|
||||
)
|
||||
return
|
||||
|
||||
const skeletonItem = inProgressItems.value.find(
|
||||
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
|
||||
)
|
||||
@@ -273,14 +297,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
)
|
||||
|
||||
return {
|
||||
inProgressItems,
|
||||
activeWorkflowInProgressItems,
|
||||
resolvedOutputsCache,
|
||||
selectedId,
|
||||
trackedJobId,
|
||||
pendingResolve,
|
||||
select,
|
||||
selectAsLatest,
|
||||
resolveIfReady,
|
||||
|
||||
inProgressItems,
|
||||
onJobStart,
|
||||
onLatentPreview,
|
||||
onNodeExecuted,
|
||||
|
||||
381
src/renderer/extensions/linearMode/useOutputHistory.test.ts
Normal file
381
src/renderer/extensions/linearMode/useOutputHistory.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
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 { 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 pendingResolveRef = ref(new Set<string>())
|
||||
const inProgressItemsRef = ref<InProgressItem[]>([])
|
||||
const selectedIdRef = ref<string | null>(null)
|
||||
const activeWorkflowPathRef = ref<string>('workflows/test.json')
|
||||
const jobIdToPathRef = ref(new Map<string, string>())
|
||||
|
||||
const selectAsLatestFn = vi.fn()
|
||||
const resolveIfReadyFn = vi.fn()
|
||||
const resolvedOutputsCacheRef = new Map<string, ResultItemImpl[]>()
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => ({
|
||||
media: mediaRef,
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
fetchMediaList: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue([]),
|
||||
loadMore: vi.fn(),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/linearOutputStore', () => ({
|
||||
useLinearOutputStore: () => ({
|
||||
get pendingResolve() {
|
||||
return pendingResolveRef.value
|
||||
},
|
||||
get inProgressItems() {
|
||||
return inProgressItemsRef.value
|
||||
},
|
||||
get selectedId() {
|
||||
return selectedIdRef.value
|
||||
},
|
||||
resolvedOutputsCache: resolvedOutputsCacheRef,
|
||||
selectAsLatest: selectAsLatestFn,
|
||||
resolveIfReady: resolveIfReadyFn
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return { path: activeWorkflowPathRef.value }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
get jobIdToSessionWorkflowPath() {
|
||||
return jobIdToPathRef.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const { jobDetailResults } = vi.hoisted(() => ({
|
||||
jobDetailResults: new Map<string, unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/jobOutputCache', () => ({
|
||||
getJobDetail: (jobId: string) =>
|
||||
Promise.resolve(jobDetailResults.get(jobId) ?? undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
||||
flattenNodeOutput: ([nodeId, output]: [
|
||||
string | number,
|
||||
Record<string, unknown>
|
||||
]) => {
|
||||
if (!output.images) return []
|
||||
return (output.images as Array<Record<string, string>>).map(
|
||||
(img) =>
|
||||
new ResultItemImpl({
|
||||
...img,
|
||||
nodeId: String(nodeId),
|
||||
mediaType: 'images'
|
||||
})
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
function makeAsset(
|
||||
id: string,
|
||||
jobId: string,
|
||||
opts?: { allOutputs?: ResultItemImpl[]; outputCount?: number }
|
||||
): AssetItem {
|
||||
return {
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
...(opts?.allOutputs ? { allOutputs: opts.allOutputs } : {}),
|
||||
...(opts?.outputCount !== undefined
|
||||
? { outputCount: opts.outputCount }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeResult(filename: string, nodeId: string = '1'): ResultItemImpl {
|
||||
return new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
})
|
||||
}
|
||||
|
||||
describe(useOutputHistory, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mediaRef.value = []
|
||||
pendingResolveRef.value = new Set()
|
||||
inProgressItemsRef.value = []
|
||||
selectedIdRef.value = null
|
||||
activeWorkflowPathRef.value = 'workflows/test.json'
|
||||
jobIdToPathRef.value = new Map()
|
||||
resolvedOutputsCacheRef.clear()
|
||||
jobDetailResults.clear()
|
||||
selectAsLatestFn.mockReset()
|
||||
resolveIfReadyFn.mockReset()
|
||||
})
|
||||
|
||||
describe('sessionMedia filtering', () => {
|
||||
it('filters assets to match active workflow path', () => {
|
||||
jobIdToPathRef.value = new Map([
|
||||
['job-1', 'workflows/test.json'],
|
||||
['job-2', 'workflows/other.json']
|
||||
])
|
||||
mediaRef.value = [makeAsset('a1', 'job-1'), makeAsset('a2', 'job-2')]
|
||||
|
||||
const { outputs } = useOutputHistory()
|
||||
|
||||
expect(outputs.media.value).toHaveLength(1)
|
||||
expect(outputs.media.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')]
|
||||
|
||||
const { outputs } = useOutputHistory()
|
||||
|
||||
expect(outputs.media.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('updates when active workflow changes', async () => {
|
||||
jobIdToPathRef.value = new Map([
|
||||
['job-1', 'workflows/a.json'],
|
||||
['job-2', 'workflows/b.json']
|
||||
])
|
||||
mediaRef.value = [makeAsset('a1', 'job-1'), makeAsset('a2', 'job-2')]
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/a.json'
|
||||
const { outputs } = useOutputHistory()
|
||||
|
||||
expect(outputs.media.value).toHaveLength(1)
|
||||
expect(outputs.media.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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('allOutputs', () => {
|
||||
it('returns empty for undefined item', () => {
|
||||
const { allOutputs } = useOutputHistory()
|
||||
|
||||
expect(allOutputs()).toEqual([])
|
||||
expect(allOutputs(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns outputs from metadata allOutputs when count matches', () => {
|
||||
const results = [makeResult('a.png'), makeResult('b.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 2
|
||||
})
|
||||
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const outputs = allOutputs(asset)
|
||||
|
||||
expect(outputs).toHaveLength(2)
|
||||
// Should be reversed
|
||||
expect(outputs[0].filename).toBe('b.png')
|
||||
expect(outputs[1].filename).toBe('a.png')
|
||||
})
|
||||
|
||||
it('filters outputs to selected output nodes only', () => {
|
||||
const results = [
|
||||
makeResult('a.png', '1'),
|
||||
makeResult('b.png', '2'),
|
||||
makeResult('c.png', '3')
|
||||
]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 3
|
||||
})
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedOutputs.push('2')
|
||||
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const outputs = allOutputs(asset)
|
||||
|
||||
expect(outputs).toHaveLength(1)
|
||||
expect(outputs[0].filename).toBe('b.png')
|
||||
})
|
||||
|
||||
it('returns all outputs when no output nodes are selected', () => {
|
||||
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 2
|
||||
})
|
||||
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const outputs = allOutputs(asset)
|
||||
|
||||
expect(outputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns consistent filtered outputs across repeated calls', () => {
|
||||
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 2
|
||||
})
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedOutputs.push('2')
|
||||
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const first = allOutputs(asset)
|
||||
const second = allOutputs(asset)
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0].filename).toBe('b.png')
|
||||
})
|
||||
|
||||
it('returns in-progress outputs for pending resolve jobs', () => {
|
||||
pendingResolveRef.value = new Set(['job-1'])
|
||||
inProgressItemsRef.value = [
|
||||
{
|
||||
id: 'item-1',
|
||||
jobId: 'job-1',
|
||||
state: 'image',
|
||||
output: makeResult('a.png')
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
jobId: 'job-1',
|
||||
state: 'image',
|
||||
output: makeResult('b.png')
|
||||
}
|
||||
]
|
||||
const asset = makeAsset('a1', 'job-1')
|
||||
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const outputs = allOutputs(asset)
|
||||
|
||||
expect(outputs).toHaveLength(2)
|
||||
expect(outputs[0].filename).toBe('a.png')
|
||||
expect(outputs[1].filename).toBe('b.png')
|
||||
})
|
||||
|
||||
it('fetches full job detail for multi-output jobs', async () => {
|
||||
jobDetailResults.set('job-1', {
|
||||
outputs: {
|
||||
'1': {
|
||||
images: [
|
||||
{ filename: 'a.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'b.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'c.png', subfolder: '', type: 'output' }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
const asset = makeAsset('a1', 'job-1')
|
||||
|
||||
const { allOutputs } = useOutputHistory()
|
||||
|
||||
expect(allOutputs(asset)).toEqual([])
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
const resolved = allOutputs(asset)
|
||||
expect(resolved).toHaveLength(3)
|
||||
expect(resolved[0].filename).toBe('c.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchEffect resolve loop', () => {
|
||||
it('resolves pending jobs when history outputs load', async () => {
|
||||
const results = [makeResult('a.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 1
|
||||
})
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
pendingResolveRef.value = new Set(['job-1'])
|
||||
mediaRef.value = [asset]
|
||||
selectedIdRef.value = null
|
||||
|
||||
useOutputHistory()
|
||||
await nextTick()
|
||||
|
||||
expect(resolveIfReadyFn).toHaveBeenCalledWith('job-1', true)
|
||||
expect(selectAsLatestFn).toHaveBeenCalledWith('history:a1:0')
|
||||
})
|
||||
|
||||
it('does not select first history when a selection exists', async () => {
|
||||
const results = [makeResult('a.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
outputCount: 1
|
||||
})
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
pendingResolveRef.value = new Set(['job-1'])
|
||||
mediaRef.value = [asset]
|
||||
selectedIdRef.value = 'history:existing:0'
|
||||
|
||||
useOutputHistory()
|
||||
await nextTick()
|
||||
|
||||
expect(resolveIfReadyFn).toHaveBeenCalledWith('job-1', true)
|
||||
expect(selectAsLatestFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips jobs with no matching asset in media', async () => {
|
||||
pendingResolveRef.value = new Set(['job-missing'])
|
||||
mediaRef.value = []
|
||||
|
||||
useOutputHistory()
|
||||
await nextTick()
|
||||
|
||||
expect(resolveIfReadyFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectFirstHistory', () => {
|
||||
it('selects first media item', () => {
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
mediaRef.value = [makeAsset('a1', 'job-1')]
|
||||
|
||||
const { selectFirstHistory } = useOutputHistory()
|
||||
selectFirstHistory()
|
||||
|
||||
expect(selectAsLatestFn).toHaveBeenCalledWith('history:a1:0')
|
||||
})
|
||||
|
||||
it('selects null when no media', () => {
|
||||
const { selectFirstHistory } = useOutputHistory()
|
||||
selectFirstHistory()
|
||||
|
||||
expect(selectAsLatestFn).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,29 +1,71 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
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 { 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 { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function useOutputHistory(): {
|
||||
outputs: IAssetsProvider
|
||||
allOutputs: (item?: AssetItem) => MaybeRef<ResultItemImpl[]>
|
||||
allOutputs: (item?: AssetItem) => ResultItemImpl[]
|
||||
selectFirstHistory: () => void
|
||||
} {
|
||||
const outputs = useMediaAssets('output')
|
||||
void outputs.fetchMediaList()
|
||||
const backingOutputs = useMediaAssets('output')
|
||||
void backingOutputs.fetchMediaList()
|
||||
const linearStore = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
|
||||
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
|
||||
const nodeIds = appModeStore.selectedOutputs
|
||||
if (!nodeIds.length) return items
|
||||
return items.filter((r) =>
|
||||
nodeIds.some((id) => String(id) === String(r.nodeId))
|
||||
)
|
||||
}
|
||||
|
||||
function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
|
||||
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
|
||||
const sessionMedia = computed(() => {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return []
|
||||
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
const pathMap = executionStore.jobIdToSessionWorkflowPath
|
||||
|
||||
return backingOutputs.media.value.filter((asset) => {
|
||||
const m = getOutputAssetMetadata(asset?.user_metadata)
|
||||
return m ? pathMap.get(m.jobId) === path : false
|
||||
})
|
||||
})
|
||||
|
||||
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']
|
||||
>()
|
||||
|
||||
function allOutputs(item?: AssetItem): 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 []
|
||||
|
||||
// For recently completed jobs still pending resolve, derive order from
|
||||
@@ -33,33 +75,70 @@ export function useOutputHistory(): {
|
||||
.filter((i) => i.jobId === user_metadata.jobId && i.output)
|
||||
.map((i) => i.output!)
|
||||
if (ordered.length > 0) {
|
||||
outputsCache[item!.id] = ordered
|
||||
return ordered
|
||||
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 &&
|
||||
user_metadata.outputCount &&
|
||||
user_metadata.outputCount <= user_metadata.allOutputs.length
|
||||
user_metadata.allOutputs?.length &&
|
||||
(!user_metadata.outputCount ||
|
||||
user_metadata.outputCount <= user_metadata.allOutputs.length)
|
||||
) {
|
||||
const reversed = user_metadata.allOutputs.toReversed()
|
||||
outputsCache[item!.id] = reversed
|
||||
return reversed
|
||||
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 []
|
||||
return Object.entries(jobDetail.outputs)
|
||||
const results = Object.entries(jobDetail.outputs)
|
||||
.flatMap(flattenNodeOutput)
|
||||
.toReversed()
|
||||
resolvedCache.set(itemId, results)
|
||||
return results
|
||||
}),
|
||||
[]
|
||||
).state
|
||||
outputsCache[item!.id] = outputRef
|
||||
return outputRef
|
||||
asyncRefs.set(item.id, outputRef)
|
||||
return filterByOutputNodes(outputRef.value)
|
||||
}
|
||||
|
||||
return { outputs, allOutputs }
|
||||
function selectFirstHistory() {
|
||||
const first = outputs.media.value[0]
|
||||
if (first) {
|
||||
linearStore.selectAsLatest(`history:${first.id}:0`)
|
||||
} else {
|
||||
linearStore.selectAsLatest(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve in-progress items when history outputs are loaded.
|
||||
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
|
||||
if (loaded) {
|
||||
linearStore.resolveIfReady(jobId, true)
|
||||
if (!linearStore.selectedId) selectFirstHistory()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { outputs, allOutputs, selectFirstHistory }
|
||||
}
|
||||
|
||||
@@ -1491,6 +1491,10 @@ export class ComfyApp {
|
||||
}
|
||||
})
|
||||
|
||||
// Capture workflow before await — activeWorkflow may change if the
|
||||
// user switches tabs while the request is in flight.
|
||||
const queuedWorkflow = useWorkspaceStore().workflow
|
||||
.activeWorkflow as ComfyWorkflow
|
||||
const p = await this.graphToPrompt(this.rootGraph)
|
||||
const queuedNodes = collectAllNodes(this.rootGraph)
|
||||
try {
|
||||
@@ -1511,8 +1515,7 @@ export class ComfyApp {
|
||||
executionStore.storeJob({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
workflow: useWorkspaceStore().workflow
|
||||
.activeWorkflow as ComfyWorkflow
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -65,6 +65,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
*/
|
||||
const jobIdToWorkflowId = ref<Map<string, string>>(new Map())
|
||||
|
||||
/**
|
||||
* Map of job ID to workflow file path in the current session.
|
||||
* Only populated for jobs that are queued in this browser tab.
|
||||
*/
|
||||
const jobIdToSessionWorkflowPath = shallowRef<Map<string, string>>(new Map())
|
||||
|
||||
const initializingJobIds = ref<Set<string>>(new Set())
|
||||
|
||||
const mergeExecutionProgressStates = (
|
||||
@@ -218,6 +224,13 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
clearInitializationByJobId(activeJobId.value)
|
||||
|
||||
// Ensure path mapping exists — execution_start can arrive via WebSocket
|
||||
// before the HTTP response from queuePrompt triggers storeJob.
|
||||
if (!jobIdToSessionWorkflowPath.value.has(activeJobId.value)) {
|
||||
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
|
||||
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
|
||||
}
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
@@ -482,6 +495,24 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (wid) {
|
||||
jobIdToWorkflowId.value.set(String(id), String(wid))
|
||||
}
|
||||
if (workflow?.path) {
|
||||
ensureSessionWorkflowPath(String(id), workflow.path)
|
||||
}
|
||||
}
|
||||
|
||||
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)
|
||||
const MAX_SESSION_PATH_ENTRIES = 4000
|
||||
|
||||
function ensureSessionWorkflowPath(jobId: string, path: string) {
|
||||
if (jobIdToSessionWorkflowPath.value.get(jobId) === path) return
|
||||
const next = new Map(jobIdToSessionWorkflowPath.value)
|
||||
next.set(jobId, path)
|
||||
while (next.size > MAX_SESSION_PATH_ENTRIES) {
|
||||
const oldest = next.keys().next().value
|
||||
if (oldest !== undefined) next.delete(oldest)
|
||||
else break
|
||||
}
|
||||
jobIdToSessionWorkflowPath.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,6 +581,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
_executingNodeProgress,
|
||||
// NodeLocatorId conversion helpers
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId
|
||||
jobIdToWorkflowId,
|
||||
jobIdToSessionWorkflowPath,
|
||||
ensureSessionWorkflowPath
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user