diff --git a/browser_tests/fixtures/utils/taskHistory.ts b/browser_tests/fixtures/utils/taskHistory.ts index 93d5b9b07..29f63849e 100644 --- a/browser_tests/fixtures/utils/taskHistory.ts +++ b/browser_tests/fixtures/utils/taskHistory.ts @@ -34,17 +34,21 @@ const getContentType = (filename: string, fileType: OutputFileType) => { } const setQueueIndex = (task: TaskItem) => { - task.prompt[0] = TaskHistory.queueIndex++ + task.prompt.priority = TaskHistory.queueIndex++ } const setPromptId = (task: TaskItem) => { - task.prompt[1] = uuidv4() + task.prompt.prompt_id = uuidv4() } export default class TaskHistory { static queueIndex = 0 static readonly defaultTask: Readonly = { - prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []], + prompt: { + priority: 0, + prompt_id: 'prompt-id', + extra_data: { client_id: uuidv4() } + }, outputs: {}, status: { status_str: 'success', @@ -75,7 +79,7 @@ export default class TaskHistory { private async handleGetView(route: Route) { const fileName = getFilenameParam(route.request()) - if (!this.outputContentTypes.has(fileName)) route.continue() + if (!this.outputContentTypes.has(fileName)) return route.continue() const asset = this.loadAsset(fileName) return route.fulfill({ diff --git a/src/components/sidebar/tabs/QueueSidebarTab.vue b/src/components/sidebar/tabs/QueueSidebarTab.vue index 6b0fe036c..a89f79440 100644 --- a/src/components/sidebar/tabs/QueueSidebarTab.vue +++ b/src/components/sidebar/tabs/QueueSidebarTab.vue @@ -106,8 +106,8 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue' import { ComfyNode } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' -import { app } from '@/scripts/app' import { useLitegraphService } from '@/services/litegraphService' +import { useWorkflowService } from '@/services/workflowService' import { useCommandStore } from '@/stores/commandStore' import { ResultItemImpl, @@ -126,6 +126,7 @@ const toast = useToast() const queueStore = useQueueStore() const settingStore = useSettingStore() const commandStore = useCommandStore() +const workflowService = useWorkflowService() const { t } = useI18n() // Expanded view: show all outputs in a flat list. @@ -208,8 +209,17 @@ const menuItems = computed(() => { { label: t('g.loadWorkflow'), icon: 'pi pi-file-export', - command: () => menuTargetTask.value?.loadWorkflow(app), - disabled: !menuTargetTask.value?.workflow + command: () => { + if (menuTargetTask.value) { + void workflowService.loadTaskWorkflow(menuTargetTask.value) + } + }, + disabled: + !menuTargetTask.value?.workflow && + !( + menuTargetTask.value?.isHistory && + menuTargetTask.value?.prompt.prompt_id + ) }, { label: t('g.goToNode'), diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 010ca077e..c062bfed0 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -134,13 +134,6 @@ export type DisplayComponentWsMessage = z.infer< > // End of ws messages -const zPromptInputItem = z.object({ - inputs: z.record(z.string(), z.any()), - class_type: zNodeType -}) - -const zPromptInputs = z.record(zPromptInputItem) - const zExtraPngInfo = z .object({ workflow: zComfyWorkflow @@ -152,7 +145,6 @@ const zExtraData = z.object({ extra_pnginfo: zExtraPngInfo.optional(), client_id: z.string() }) -const zOutputsToExecute = z.array(zNodeId) const zExecutionStartMessage = z.tuple([ z.literal('execution_start'), @@ -193,13 +185,11 @@ const zStatus = z.object({ messages: z.array(zStatusMessage) }) -const zTaskPrompt = z.tuple([ - zQueueIndex, - zPromptId, - zPromptInputs, - zExtraData, - zOutputsToExecute -]) +const zTaskPrompt = z.object({ + priority: zQueueIndex, + prompt_id: zPromptId, + extra_data: zExtraData +}) const zRunningTaskItem = z.object({ taskType: z.literal('Running'), @@ -235,6 +225,20 @@ const zHistoryTaskItem = z.object({ meta: zTaskMeta.optional() }) +// Raw history item from backend (without taskType) +const zRawHistoryItem = z.object({ + prompt_id: zPromptId, + prompt: zTaskPrompt, + status: zStatus.optional(), + outputs: zTaskOutput, + meta: zTaskMeta.optional() +}) + +// New API response format: { history: [{prompt_id: "...", ...}, ...] } +const zHistoryResponse = z.object({ + history: z.array(zRawHistoryItem) +}) + const zTaskItem = z.union([ zRunningTaskItem, zPendingTaskItem, @@ -257,6 +261,8 @@ export type RunningTaskItem = z.infer export type PendingTaskItem = z.infer // `/history` export type HistoryTaskItem = z.infer +export type RawHistoryItem = z.infer +export type HistoryResponse = z.infer export type TaskItem = z.infer export function validateTaskItem(taskItem: unknown) { diff --git a/src/scripts/api.ts b/src/scripts/api.ts index b17df5bac..bf00ee639 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -11,6 +11,7 @@ import type { ExecutionStartWsMessage, ExecutionSuccessWsMessage, ExtensionsResponse, + HistoryResponse, HistoryTaskItem, LogsRawResponse, LogsWsMessage, @@ -23,6 +24,7 @@ import type { StatusWsMessage, StatusWsMessageStatus, SystemStats, + TaskPrompt, User, UserDataFullInfo } from '@/schemas/apiSchema' @@ -686,13 +688,12 @@ export class ComfyApi extends EventTarget { const data = await res.json() return { // Running action uses a different endpoint for cancelling - Running: data.queue_running.map((prompt: Record) => ({ + Running: data.queue_running.map((prompt: TaskPrompt) => ({ taskType: 'Running', prompt, - // prompt[1] is the prompt id - remove: { name: 'Cancel', cb: () => api.interrupt(prompt[1]) } + remove: { name: 'Cancel', cb: () => api.interrupt(prompt.prompt_id) } })), - Pending: data.queue_pending.map((prompt: Record) => ({ + Pending: data.queue_pending.map((prompt: TaskPrompt) => ({ taskType: 'Pending', prompt })) @@ -711,13 +712,17 @@ export class ComfyApi extends EventTarget { max_items: number = 200 ): Promise<{ History: HistoryTaskItem[] }> { try { - const res = await this.fetchApi(`/history?max_items=${max_items}`) - const json: Promise = await res.json() + const res = await this.fetchApi(`/history_v2?max_items=${max_items}`) + const json: HistoryResponse = await res.json() + + // Extract history data from new format: { history: [{prompt_id: "...", ...}, ...] } return { - History: Object.values(json).map((item) => ({ - ...item, - taskType: 'History' - })) + History: json.history.map( + (item): HistoryTaskItem => ({ + ...item, + taskType: 'History' + }) + ) } } catch (error) { console.error(error) @@ -725,6 +730,33 @@ export class ComfyApi extends EventTarget { } } + /** + * Gets workflow data for a specific prompt from history + * @param prompt_id The prompt ID to fetch workflow for + * @returns Workflow data for the specific prompt + */ + async getWorkflowFromHistory( + prompt_id: string + ): Promise { + try { + const res = await this.fetchApi(`/history_v2/${prompt_id}`) + const json = await res.json() + + // The /history_v2/{prompt_id} endpoint returns data for a specific prompt + // The response format is: { prompt_id: { prompt: [...], outputs: {...}, status: {...} } } + const historyItem = json[prompt_id] + if (!historyItem) return null + + // Extract workflow from the prompt array + // prompt[3] contains extra_data which has extra_pnginfo.workflow + const workflow = historyItem.prompt?.[3]?.extra_pnginfo?.workflow + return workflow || null + } catch (error) { + console.error(`Failed to fetch workflow for prompt ${prompt_id}:`, error) + return null + } + } + /** * Gets system & device stats * @returns System stats such as python version, OS, per device info diff --git a/src/scripts/ui.ts b/src/scripts/ui.ts index b2d075665..56251f9d9 100644 --- a/src/scripts/ui.ts +++ b/src/scripts/ui.ts @@ -264,15 +264,16 @@ class ComfyList { ? item.remove : { name: 'Delete', - cb: () => api.deleteItem(this.#type, item.prompt[1]) + cb: () => + api.deleteItem(this.#type, item.prompt.prompt_id) } - return $el('div', { textContent: item.prompt[0] + ': ' }, [ + return $el('div', { textContent: item.prompt.priority + ': ' }, [ $el('button', { textContent: 'Load', onclick: async () => { await app.loadGraphData( // @ts-expect-error fixme ts strict error - item.prompt[3].extra_pnginfo.workflow, + item.prompt.extra_data.extra_pnginfo.workflow, true, false ) diff --git a/src/services/workflowService.ts b/src/services/workflowService.ts index 4fcbd44b5..e3276512d 100644 --- a/src/services/workflowService.ts +++ b/src/services/workflowService.ts @@ -4,10 +4,12 @@ import { toRaw } from 'vue' import { t } from '@/i18n' import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' +import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { blankGraph, defaultGraph } from '@/scripts/defaultGraph' import { downloadBlob } from '@/scripts/utils' import { useDomWidgetStore } from '@/stores/domWidgetStore' +import { TaskItemImpl } from '@/stores/queueStore' import { useSettingStore } from '@/stores/settingStore' import { useToastStore } from '@/stores/toastStore' import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore' @@ -152,6 +154,32 @@ export const useWorkflowService = () => { await app.loadGraphData(blankGraph) } + /** + * Load a workflow from a task item (queue/history) + * For history items, fetches workflow data from /history_v2/{prompt_id} + * @param task The task item to load the workflow from + */ + const loadTaskWorkflow = async (task: TaskItemImpl) => { + let workflowData = task.workflow + + // History items don't include workflow data - fetch from API + if (task.isHistory) { + const promptId = task.prompt.prompt_id + if (promptId) { + workflowData = (await api.getWorkflowFromHistory(promptId)) || undefined + } + } + + if (!workflowData) { + return + } + + await app.loadGraphData(toRaw(workflowData)) + if (task.outputs) { + app.nodeOutputs = toRaw(task.outputs) + } + } + /** * Reload the current workflow * This is used to refresh the node definitions update, e.g. when the locale changes. @@ -394,6 +422,7 @@ export const useWorkflowService = () => { saveWorkflow, loadDefaultWorkflow, loadBlankWorkflow, + loadTaskWorkflow, reloadCurrentWorkflow, openWorkflow, closeWorkflow, diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index 376063958..0b3b3574f 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -269,23 +269,15 @@ export class TaskItemImpl { } get queueIndex() { - return this.prompt[0] + return this.prompt.priority } get promptId() { - return this.prompt[1] - } - - get promptInputs() { - return this.prompt[2] + return this.prompt.prompt_id } get extraData() { - return this.prompt[3] - } - - get outputsToExecute() { - return this.prompt[4] + return this.prompt.extra_data } get extraPngInfo() { @@ -390,13 +382,11 @@ export class TaskItemImpl { (output: ResultItemImpl, i: number) => new TaskItemImpl( this.taskType, - [ - this.queueIndex, - `${this.promptId}-${i}`, - this.promptInputs, - this.extraData, - this.outputsToExecute - ], + { + priority: this.queueIndex, + prompt_id: `${this.promptId}-${i}`, + extra_data: this.extraData + }, this.status, { [output.nodeId]: { @@ -461,11 +451,11 @@ export const useQueueStore = defineStore('queue', () => { pendingTasks.value = toClassAll(queue.Pending) const allIndex = new Set( - history.History.map((item: TaskItem) => item.prompt[0]) + history.History.map((item: TaskItem) => item.prompt.priority) ) const newHistoryItems = toClassAll( history.History.filter( - (item) => item.prompt[0] > lastHistoryQueueIndex.value + (item) => item.prompt.priority > lastHistoryQueueIndex.value ) ) const existingHistoryItems = historyTasks.value.filter((item) => diff --git a/tests-ui/tests/scripts/api.test.ts b/tests-ui/tests/scripts/api.test.ts new file mode 100644 index 000000000..a4c11c208 --- /dev/null +++ b/tests-ui/tests/scripts/api.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { + HistoryResponse, + RawHistoryItem +} from '../../../src/schemas/apiSchema' +import { ComfyApi } from '../../../src/scripts/api' + +describe('ComfyApi getHistory', () => { + let api: ComfyApi + + beforeEach(() => { + api = new ComfyApi() + }) + + const mockHistoryItem: RawHistoryItem = { + prompt_id: 'test_prompt_id', + prompt: { + priority: 0, + prompt_id: 'test_prompt_id', + extra_data: { + extra_pnginfo: { + workflow: { + last_node_id: 1, + last_link_id: 0, + nodes: [], + links: [], + groups: [], + config: {}, + extra: {}, + version: 0.4 + } + }, + client_id: 'test_client_id' + } + }, + outputs: {}, + status: { + status_str: 'success', + completed: true, + messages: [] + } + } + + describe('history v2 API format', () => { + it('should handle history array format from /history_v2', async () => { + const historyResponse: HistoryResponse = { + history: [ + { ...mockHistoryItem, prompt_id: 'prompt_id_1' }, + { ...mockHistoryItem, prompt_id: 'prompt_id_2' } + ] + } + + // Mock fetchApi to return the v2 format + const mockFetchApi = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue(historyResponse) + }) + api.fetchApi = mockFetchApi + + const result = await api.getHistory(10) + + expect(result.History).toHaveLength(2) + expect(result.History[0]).toEqual({ + ...mockHistoryItem, + prompt_id: 'prompt_id_1', + taskType: 'History' + }) + expect(result.History[1]).toEqual({ + ...mockHistoryItem, + prompt_id: 'prompt_id_2', + taskType: 'History' + }) + }) + + it('should handle empty history array', async () => { + const historyResponse: HistoryResponse = { + history: [] + } + + const mockFetchApi = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue(historyResponse) + }) + api.fetchApi = mockFetchApi + + const result = await api.getHistory(10) + + expect(result.History).toHaveLength(0) + expect(result.History).toEqual([]) + }) + }) + + describe('error handling', () => { + it('should return empty history on error', async () => { + const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error')) + api.fetchApi = mockFetchApi + + const result = await api.getHistory() + + expect(result.History).toEqual([]) + }) + }) + + describe('API call parameters', () => { + it('should call fetchApi with correct v2 endpoint and parameters', async () => { + const mockFetchApi = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ history: [] }) + }) + api.fetchApi = mockFetchApi + + await api.getHistory(50) + + expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=50') + }) + + it('should use default max_items parameter with v2 endpoint', async () => { + const mockFetchApi = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ history: [] }) + }) + api.fetchApi = mockFetchApi + + await api.getHistory() + + expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=200') + }) + }) +}) diff --git a/tests-ui/tests/store/queueStore.test.ts b/tests-ui/tests/store/queueStore.test.ts index 313673e69..2bfcc851c 100644 --- a/tests-ui/tests/store/queueStore.test.ts +++ b/tests-ui/tests/store/queueStore.test.ts @@ -6,7 +6,11 @@ describe('TaskItemImpl', () => { it('should remove animated property from outputs during construction', () => { const taskItem = new TaskItemImpl( 'History', - [0, 'prompt-id', {}, { client_id: 'client-id' }, []], + { + priority: 0, + prompt_id: 'prompt-id', + extra_data: { client_id: 'client-id' } + }, { status_str: 'success', messages: [], completed: true }, { 'node-1': { @@ -26,7 +30,11 @@ describe('TaskItemImpl', () => { it('should handle outputs without animated property', () => { const taskItem = new TaskItemImpl( 'History', - [0, 'prompt-id', {}, { client_id: 'client-id' }, []], + { + priority: 0, + prompt_id: 'prompt-id', + extra_data: { client_id: 'client-id' } + }, { status_str: 'success', messages: [], completed: true }, { 'node-1': { @@ -42,7 +50,11 @@ describe('TaskItemImpl', () => { it('should recognize webm video from core', () => { const taskItem = new TaskItemImpl( 'History', - [0, 'prompt-id', {}, { client_id: 'client-id' }, []], + { + priority: 0, + prompt_id: 'prompt-id', + extra_data: { client_id: 'client-id' } + }, { status_str: 'success', messages: [], completed: true }, { 'node-1': { @@ -64,7 +76,11 @@ describe('TaskItemImpl', () => { it('should recognize webm video from VHS', () => { const taskItem = new TaskItemImpl( 'History', - [0, 'prompt-id', {}, { client_id: 'client-id' }, []], + { + priority: 0, + prompt_id: 'prompt-id', + extra_data: { client_id: 'client-id' } + }, { status_str: 'success', messages: [], completed: true }, { 'node-1': { @@ -93,7 +109,11 @@ describe('TaskItemImpl', () => { it('should recognize mp4 video from core', () => { const taskItem = new TaskItemImpl( 'History', - [0, 'prompt-id', {}, { client_id: 'client-id' }, []], + { + priority: 0, + prompt_id: 'prompt-id', + extra_data: { client_id: 'client-id' } + }, { status_str: 'success', messages: [], completed: true }, { 'node-1': { @@ -128,7 +148,11 @@ describe('TaskItemImpl', () => { it(`should recognize ${extension} audio`, () => { const taskItem = new TaskItemImpl( 'History', - [0, 'prompt-id', {}, { client_id: 'client-id' }, []], + { + priority: 0, + prompt_id: 'prompt-id', + extra_data: { client_id: 'client-id' } + }, { status_str: 'success', messages: [], completed: true }, { 'node-1': {