feat(historyV2): load workflows for images (#6384)

## Summary

Hooked up the "Load Workflow" action to our `history_v2` API.

Note: Our cloud envs were being stress tested right now so images are
loading at time of recording. Images were loading for me during
development before I had time to create the video.


## Screenshots 📷 



https://github.com/user-attachments/assets/02145504-ceae-497b-9049-553796d698da



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6384-Feat-history-v2-workflows-29b6d73d365081bcb706fe799b8ce66a)
by [Unito](https://www.unito.io)
This commit is contained in:
Arjan Singh
2025-10-29 18:35:42 -07:00
committed by GitHub
parent 6f068c87da
commit f2355a6ad1
9 changed files with 384 additions and 64 deletions

View File

@@ -104,6 +104,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
@@ -206,7 +207,9 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
disabled: isCloud
? !menuTargetTask.value?.isHistory
: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),

View File

@@ -13,11 +13,6 @@
import { isCloud } from '@/platform/distribution/types'
import type { TaskItem } from '@/schemas/apiSchema'
interface ReconciliationResult {
/** All items to display, sorted by queueIndex descending (newest first) */
items: TaskItem[]
}
/**
* V1 reconciliation: QueueIndex-based filtering works because V1 has stable,
* monotonically increasing queue indices.
@@ -25,13 +20,15 @@ interface ReconciliationResult {
* Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure
* consistent ordering. JavaScript .filter() maintains iteration order, so filtered
* results remain sorted. clientHistory is assumed already sorted from previous update.
*
* @returns All items to display, sorted by queueIndex descending (newest first)
*/
function reconcileHistoryV1(
serverHistory: TaskItem[],
clientHistory: TaskItem[],
maxItems: number,
lastKnownQueueIndex: number | undefined
): ReconciliationResult {
): TaskItem[] {
const sortedServerHistory = serverHistory.sort(
(a, b) => b.prompt[0] - a.prompt[0]
)
@@ -53,13 +50,9 @@ function reconcileHistoryV1(
)
// Merge new and reused items, sort by queueIndex descending, limit to maxItems
const allItems = [...itemsAddedSinceLastSync, ...clientItemsStillOnServer]
return [...itemsAddedSinceLastSync, ...clientItemsStillOnServer]
.sort((a, b) => b.prompt[0] - a.prompt[0])
.slice(0, maxItems)
return {
items: allItems
}
}
/**
@@ -69,12 +62,14 @@ function reconcileHistoryV1(
* Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure
* consistent ordering. JavaScript .filter() maintains iteration order, so filtered
* results remain sorted. clientHistory is assumed already sorted from previous update.
*
* @returns All items to display, sorted by queueIndex descending (newest first)
*/
function reconcileHistoryV2(
serverHistory: TaskItem[],
clientHistory: TaskItem[],
maxItems: number
): ReconciliationResult {
): TaskItem[] {
const sortedServerHistory = serverHistory.sort(
(a, b) => b.prompt[0] - a.prompt[0]
)
@@ -84,29 +79,18 @@ function reconcileHistoryV2(
)
const clientPromptIds = new Set(clientHistory.map((item) => item.prompt[1]))
const newPromptIds = new Set(
[...serverPromptIds].filter((id) => !clientPromptIds.has(id))
const newItems = sortedServerHistory.filter(
(item) => !clientPromptIds.has(item.prompt[1])
)
const newItems = sortedServerHistory.filter((item) =>
newPromptIds.has(item.prompt[1])
)
const retainedPromptIds = new Set(
[...serverPromptIds].filter((id) => clientPromptIds.has(id))
)
const clientItemsStillOnServer = clientHistory.filter((item) =>
retainedPromptIds.has(item.prompt[1])
serverPromptIds.has(item.prompt[1])
)
// Merge new and reused items, sort by queueIndex descending, limit to maxItems
const allItems = [...newItems, ...clientItemsStillOnServer]
return [...newItems, ...clientItemsStillOnServer]
.sort((a, b) => b.prompt[0] - a.prompt[0])
.slice(0, maxItems)
return {
items: allItems
}
}
/**
@@ -125,7 +109,7 @@ export function reconcileHistory(
clientHistory: TaskItem[],
maxItems: number,
lastKnownQueueIndex?: number
): ReconciliationResult {
): TaskItem[] {
if (isCloud) {
return reconcileHistoryV2(serverHistory, clientHistory, maxItems)
}

View File

@@ -0,0 +1,21 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { PromptId } from '@/schemas/apiSchema'
export async function getWorkflowFromHistory(
fetchApi: (url: string) => Promise<Response>,
promptId: PromptId
): Promise<ComfyWorkflowJSON | undefined> {
try {
const res = await fetchApi(`/history_v2/${promptId}`)
const json = await res.json()
const historyItem = json[promptId]
if (!historyItem) return undefined
const workflow = historyItem.prompt?.extra_data?.extra_pnginfo?.workflow
return workflow ?? undefined
} catch (error) {
console.error(`Failed to fetch workflow for prompt ${promptId}:`, error)
return undefined
}
}

View File

@@ -0,0 +1,10 @@
/**
* Cloud: Fetches workflow by prompt_id. Desktop: Returns undefined (workflows already in history).
*/
import { isCloud } from '@/platform/distribution/types'
import { getWorkflowFromHistory as cloudImpl } from './getWorkflowFromHistory'
export const getWorkflowFromHistory = isCloud
? cloudImpl
: async () => undefined

View File

@@ -13,6 +13,7 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
export const zQueueIndex = z.number()
export const zPromptId = z.string()
export type PromptId = z.infer<typeof zPromptId>
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>

View File

@@ -2,7 +2,9 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
import { getWorkflowFromHistory } from '@/platform/workflow/cloud'
import type {
ComfyWorkflowJSON,
NodeId
@@ -379,24 +381,37 @@ export class TaskItemImpl {
}
public async loadWorkflow(app: ComfyApp) {
if (!this.workflow) {
return
}
await app.loadGraphData(toRaw(this.workflow))
if (this.outputs) {
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(this.outputs)
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
)
}
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
let workflowData = this.workflow
if (isCloud && !workflowData && this.isHistory) {
workflowData = await getWorkflowFromHistory(
(url) => app.api.fetchApi(url),
this.promptId
)
}
if (!workflowData) {
return
}
await app.loadGraphData(toRaw(workflowData))
if (!this.outputs) {
return
}
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(this.outputs)
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
)
}
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
)
}
public flatten(): TaskItemImpl[] {
@@ -492,7 +507,7 @@ export const useQueueStore = defineStore('queue', () => {
const currentHistory = toValue(historyTasks)
const { items } = reconcileHistory(
const items = reconcileHistory(
history.History,
currentHistory.map((impl) => impl.toTaskItem()),
toValue(maxHistoryItems),