mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +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">
|
<script setup lang="ts">
|
||||||
import { useEventListener, useInfiniteScroll } from '@vueuse/core'
|
import { useEventListener } from '@vueuse/core'
|
||||||
import { ListboxContent, ListboxItem, ListboxRoot } from 'reka-ui'
|
import { ListboxContent, ListboxItem, ListboxRoot } from 'reka-ui'
|
||||||
import {
|
import { computed, nextTick, useTemplateRef, watch, watchEffect } from 'vue'
|
||||||
computed,
|
|
||||||
nextTick,
|
|
||||||
toValue,
|
|
||||||
useTemplateRef,
|
|
||||||
watch,
|
|
||||||
watchEffect
|
|
||||||
} from 'vue'
|
|
||||||
|
|
||||||
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
|
||||||
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
|
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
|
||||||
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
||||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||||
@@ -24,7 +16,7 @@ import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHist
|
|||||||
import { useQueueStore } from '@/stores/queueStore'
|
import { useQueueStore } from '@/stores/queueStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { outputs, allOutputs } = useOutputHistory()
|
const { outputs, allOutputs, selectFirstHistory } = useOutputHistory()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
const store = useLinearOutputStore()
|
const store = useLinearOutputStore()
|
||||||
|
|
||||||
@@ -45,15 +37,17 @@ const itemClass = cn(
|
|||||||
'data-[state=checked]:border-interface-panel-job-progress-border'
|
'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(() =>
|
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 selectableItems = computed(() => {
|
||||||
const items: SelectionValue[] = []
|
const items: SelectionValue[] = []
|
||||||
for (const item of store.inProgressItems) {
|
for (const item of store.activeWorkflowInProgressItems) {
|
||||||
items.push({
|
items.push({
|
||||||
id: `slot:${item.id}`,
|
id: `slot:${item.id}`,
|
||||||
kind: 'inProgress',
|
kind: 'inProgress',
|
||||||
@@ -61,7 +55,7 @@ const selectableItems = computed(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const asset of outputs.media.value) {
|
for (const asset of outputs.media.value) {
|
||||||
const outs = toValue(allOutputs(asset))
|
const outs = allOutputs(asset)
|
||||||
for (let k = 0; k < outs.length; k++) {
|
for (let k = 0; k < outs.length; k++) {
|
||||||
items.push({
|
items.push({
|
||||||
id: `history:${asset.id}:${k}`,
|
id: `history:${asset.id}:${k}`,
|
||||||
@@ -95,7 +89,9 @@ function doEmit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (sel.kind === 'inProgress') {
|
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') {
|
if (!item || item.state === 'skeleton') {
|
||||||
emit('updateSelection', { canShowPreview: true })
|
emit('updateSelection', { canShowPreview: true })
|
||||||
} else if (item.state === 'latent') {
|
} else if (item.state === 'latent') {
|
||||||
@@ -112,7 +108,7 @@ function doEmit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
|
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
|
const isFirst = outputs.media.value[0]?.id === sel.assetId
|
||||||
emit('updateSelection', {
|
emit('updateSelection', {
|
||||||
asset,
|
asset,
|
||||||
@@ -123,24 +119,6 @@ function doEmit() {
|
|||||||
|
|
||||||
watchEffect(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
|
// Keep history selection stable on media changes
|
||||||
watch(
|
watch(
|
||||||
() => outputs.media.value,
|
() => 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')
|
const outputsRef = useTemplateRef('outputsRef')
|
||||||
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
|
||||||
canLoadMore: () => outputs.hasMore.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reka UI's ListboxContent stops propagation on ALL Enter keydown events,
|
// Reka UI's ListboxContent stops propagation on ALL Enter keydown events,
|
||||||
// which blocks modifier+Enter (Ctrl+Enter = run workflow) from reaching
|
// which blocks modifier+Enter (Ctrl+Enter = run workflow) from reaching
|
||||||
@@ -296,7 +262,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ListboxItem
|
<ListboxItem
|
||||||
v-for="item in store.inProgressItems"
|
v-for="item in store.activeWorkflowInProgressItems"
|
||||||
:key="`${item.id}-${item.state}`"
|
:key="`${item.id}-${item.state}`"
|
||||||
:value="{
|
:value="{
|
||||||
id: `slot:${item.id}`,
|
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"
|
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||||
/>
|
/>
|
||||||
<ListboxItem
|
<ListboxItem
|
||||||
v-for="(output, key) in toValue(allOutputs(asset))"
|
v-for="(output, key) in allOutputs(asset)"
|
||||||
:key
|
:key
|
||||||
:value="{
|
:value="{
|
||||||
id: `history:${asset.id}:${key}`,
|
id: `history:${asset.id}:${key}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
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 { ref } from 'vue'
|
||||||
|
|
||||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||||
@@ -9,6 +9,9 @@ import { ResultItemImpl } from '@/stores/queueStore'
|
|||||||
const activeJobIdRef = ref<string | null>(null)
|
const activeJobIdRef = ref<string | null>(null)
|
||||||
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
|
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
|
||||||
const isAppModeRef = ref(true)
|
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(() => ({
|
const { apiTarget } = vi.hoisted(() => ({
|
||||||
apiTarget: new EventTarget()
|
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', () => ({
|
vi.mock('@/stores/executionStore', () => ({
|
||||||
useExecutionStore: () => ({
|
useExecutionStore: () => ({
|
||||||
get activeJobId() {
|
get activeJobId() {
|
||||||
return activeJobIdRef.value
|
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(
|
function makeExecutedDetail(
|
||||||
promptId: string,
|
promptId: string,
|
||||||
images: Array<Record<string, string>> = [
|
images: Array<Record<string, string>> = [
|
||||||
@@ -80,11 +108,9 @@ describe('linearOutputStore', () => {
|
|||||||
activeJobIdRef.value = null
|
activeJobIdRef.value = null
|
||||||
previewsRef.value = {}
|
previewsRef.value = {}
|
||||||
isAppModeRef.value = true
|
isAppModeRef.value = true
|
||||||
})
|
activeWorkflowPathRef.value = 'workflows/test-workflow.json'
|
||||||
|
jobIdToWorkflowPathRef.value = new Map()
|
||||||
afterEach(() => {
|
selectedOutputsRef.value = []
|
||||||
activeJobIdRef.value = null
|
|
||||||
previewsRef.value = {}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates a skeleton item when a job starts', () => {
|
it('creates a skeleton item when a job starts', () => {
|
||||||
@@ -613,10 +639,105 @@ describe('linearOutputStore', () => {
|
|||||||
|
|
||||||
expect(store.inProgressItems).toHaveLength(0)
|
expect(store.inProgressItems).toHaveLength(0)
|
||||||
expect(store.selectedId).toBeNull()
|
expect(store.selectedId).toBeNull()
|
||||||
expect(store.trackedJobId).toBeNull()
|
|
||||||
expect(store.pendingResolve.size).toBe(0)
|
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 () => {
|
it('ignores execution events when not in app mode', async () => {
|
||||||
const { nextTick } = await import('vue')
|
const { nextTick } = await import('vue')
|
||||||
const store = useLinearOutputStore()
|
const store = useLinearOutputStore()
|
||||||
|
|||||||
@@ -1,26 +1,41 @@
|
|||||||
import { defineStore } from 'pinia'
|
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 { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||||
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { useAppMode } from '@/composables/useAppMode'
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||||
|
|
||||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||||
const { isAppMode } = useAppMode()
|
const { isAppMode } = useAppMode()
|
||||||
|
const appModeStore = useAppModeStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
const jobPreviewStore = useJobPreviewStore()
|
const jobPreviewStore = useJobPreviewStore()
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
|
||||||
const inProgressItems = ref<InProgressItem[]>([])
|
const inProgressItems = ref<InProgressItem[]>([])
|
||||||
|
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const isFollowing = ref(true)
|
const isFollowing = ref(true)
|
||||||
const trackedJobId = ref<string | null>(null)
|
const trackedJobId = ref<string | null>(null)
|
||||||
const pendingResolve = ref(new Set<string>())
|
const pendingResolve = ref(new Set<string>())
|
||||||
const executedNodeIds = 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
|
let nextSeq = 0
|
||||||
|
|
||||||
function makeItemId(jobId: string): string {
|
function makeItemId(jobId: string): string {
|
||||||
@@ -42,6 +57,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
|
|
||||||
function onJobStart(jobId: string) {
|
function onJobStart(jobId: string) {
|
||||||
executedNodeIds.clear()
|
executedNodeIds.clear()
|
||||||
|
|
||||||
const item: InProgressItem = {
|
const item: InProgressItem = {
|
||||||
id: makeItemId(jobId),
|
id: makeItemId(jobId),
|
||||||
jobId,
|
jobId,
|
||||||
@@ -101,6 +117,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
||||||
if (newOutputs.length === 0) return
|
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(
|
const skeletonItem = inProgressItems.value.find(
|
||||||
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
|
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
|
||||||
)
|
)
|
||||||
@@ -273,14 +297,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inProgressItems,
|
activeWorkflowInProgressItems,
|
||||||
|
resolvedOutputsCache,
|
||||||
selectedId,
|
selectedId,
|
||||||
trackedJobId,
|
|
||||||
pendingResolve,
|
pendingResolve,
|
||||||
select,
|
select,
|
||||||
selectAsLatest,
|
selectAsLatest,
|
||||||
resolveIfReady,
|
resolveIfReady,
|
||||||
|
inProgressItems,
|
||||||
onJobStart,
|
onJobStart,
|
||||||
onLatentPreview,
|
onLatentPreview,
|
||||||
onNodeExecuted,
|
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 { 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 type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||||
|
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||||
import { getJobDetail } from '@/services/jobOutputCache'
|
import { getJobDetail } from '@/services/jobOutputCache'
|
||||||
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
export function useOutputHistory(): {
|
export function useOutputHistory(): {
|
||||||
outputs: IAssetsProvider
|
outputs: IAssetsProvider
|
||||||
allOutputs: (item?: AssetItem) => MaybeRef<ResultItemImpl[]>
|
allOutputs: (item?: AssetItem) => ResultItemImpl[]
|
||||||
|
selectFirstHistory: () => void
|
||||||
} {
|
} {
|
||||||
const outputs = useMediaAssets('output')
|
const backingOutputs = useMediaAssets('output')
|
||||||
void outputs.fetchMediaList()
|
void backingOutputs.fetchMediaList()
|
||||||
const linearStore = useLinearOutputStore()
|
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[]> {
|
const sessionMedia = computed(() => {
|
||||||
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
|
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 []
|
if (!user_metadata) return []
|
||||||
|
|
||||||
// For recently completed jobs still pending resolve, derive order from
|
// 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)
|
.filter((i) => i.jobId === user_metadata.jobId && i.output)
|
||||||
.map((i) => i.output!)
|
.map((i) => i.output!)
|
||||||
if (ordered.length > 0) {
|
if (ordered.length > 0) {
|
||||||
outputsCache[item!.id] = ordered
|
resolvedCache.set(item.id, ordered)
|
||||||
return 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 (
|
if (
|
||||||
user_metadata.allOutputs &&
|
user_metadata.allOutputs?.length &&
|
||||||
user_metadata.outputCount &&
|
(!user_metadata.outputCount ||
|
||||||
user_metadata.outputCount <= user_metadata.allOutputs.length
|
user_metadata.outputCount <= user_metadata.allOutputs.length)
|
||||||
) {
|
) {
|
||||||
const reversed = user_metadata.allOutputs.toReversed()
|
const reversed = user_metadata.allOutputs.toReversed()
|
||||||
outputsCache[item!.id] = reversed
|
resolvedCache.set(item.id, reversed)
|
||||||
return 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(
|
const outputRef = useAsyncState(
|
||||||
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
||||||
if (!jobDetail?.outputs) return []
|
if (!jobDetail?.outputs) return []
|
||||||
return Object.entries(jobDetail.outputs)
|
const results = Object.entries(jobDetail.outputs)
|
||||||
.flatMap(flattenNodeOutput)
|
.flatMap(flattenNodeOutput)
|
||||||
.toReversed()
|
.toReversed()
|
||||||
|
resolvedCache.set(itemId, results)
|
||||||
|
return results
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
).state
|
).state
|
||||||
outputsCache[item!.id] = outputRef
|
asyncRefs.set(item.id, outputRef)
|
||||||
return 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 p = await this.graphToPrompt(this.rootGraph)
|
||||||
const queuedNodes = collectAllNodes(this.rootGraph)
|
const queuedNodes = collectAllNodes(this.rootGraph)
|
||||||
try {
|
try {
|
||||||
@@ -1511,8 +1515,7 @@ export class ComfyApp {
|
|||||||
executionStore.storeJob({
|
executionStore.storeJob({
|
||||||
id: res.prompt_id,
|
id: res.prompt_id,
|
||||||
nodes: Object.keys(p.output),
|
nodes: Object.keys(p.output),
|
||||||
workflow: useWorkspaceStore().workflow
|
workflow: queuedWorkflow
|
||||||
.activeWorkflow as ComfyWorkflow
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
@@ -65,6 +65,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
*/
|
*/
|
||||||
const jobIdToWorkflowId = ref<Map<string, string>>(new Map())
|
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 initializingJobIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const mergeExecutionProgressStates = (
|
const mergeExecutionProgressStates = (
|
||||||
@@ -218,6 +224,13 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
activeJobId.value = e.detail.prompt_id
|
activeJobId.value = e.detail.prompt_id
|
||||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||||
clearInitializationByJobId(activeJobId.value)
|
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>) {
|
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||||
@@ -482,6 +495,24 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
if (wid) {
|
if (wid) {
|
||||||
jobIdToWorkflowId.value.set(String(id), String(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,
|
_executingNodeProgress,
|
||||||
// NodeLocatorId conversion helpers
|
// NodeLocatorId conversion helpers
|
||||||
nodeLocatorIdToExecutionId,
|
nodeLocatorIdToExecutionId,
|
||||||
jobIdToWorkflowId
|
jobIdToWorkflowId,
|
||||||
|
jobIdToSessionWorkflowPath,
|
||||||
|
ensureSessionWorkflowPath
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user