mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-08 17:10:07 +00:00
Implement workflow progress panel (#6092)
Adds a workflow progress panel component underneath the `actionbar-container`. I suggest starting a review at the extraneous changes that were needed. Including but not limited to: - `get createTime()` in queueStore - `promptIdToWorkflowId`, `initializingPromptIds`, and `nodeProgressStatesByPrompt` in executionStore - `create_time` handling in v2ToV1Adapter - `pointer-events-auto` on ComfyActionbar.vue The rest of the changes should be contained under `QueueProgressOverlay.vue`, and has less of a blast radius in case something goes wrong. --------- Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Jin Yi <jin12cc@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com> Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
This commit is contained in:
356
src/composables/queue/useJobMenu.ts
Normal file
356
src/composables/queue/useJobMenu.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { st, t } from '@/i18n'
|
||||
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
ResultItem,
|
||||
ResultItemType
|
||||
} from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
export type MenuEntry =
|
||||
| {
|
||||
kind?: 'item'
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
onClick?: () => void | Promise<void>
|
||||
}
|
||||
| { kind: 'divider'; key: string }
|
||||
|
||||
/**
|
||||
* Provides job context menu entries and actions.
|
||||
*
|
||||
* @param currentMenuItem Getter for the currently targeted job list item
|
||||
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
|
||||
*/
|
||||
export function useJobMenu(
|
||||
currentMenuItem: () => JobListItem | null,
|
||||
onInspectAsset?: (item: JobListItem) => void
|
||||
) {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const queueStore = useQueueStore()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const litegraphService = useLitegraphService()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const mediaAssetActions = useMediaAssetActions()
|
||||
|
||||
const openJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = item.taskRef?.workflow
|
||||
if (!data) return
|
||||
const filename = `Job ${item.id}.json`
|
||||
const temp = workflowStore.createTemporary(filename, data)
|
||||
await workflowService.openWorkflow(temp)
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
await copyToClipboard(item.id)
|
||||
}
|
||||
|
||||
const cancelJob = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
await api.interrupt(item.id)
|
||||
} else if (item.state === 'pending') {
|
||||
await api.deleteItem('queue', item.id)
|
||||
}
|
||||
await queueStore.update()
|
||||
}
|
||||
|
||||
const copyErrorMessage = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const msgs = item.taskRef?.status?.messages as any[] | undefined
|
||||
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
|
||||
| ExecutionErrorWsMessage
|
||||
| undefined
|
||||
const message = err?.exception_message
|
||||
if (message) await copyToClipboard(String(message))
|
||||
}
|
||||
|
||||
const reportError = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const msgs = item.taskRef?.status?.messages as any[] | undefined
|
||||
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
|
||||
| ExecutionErrorWsMessage
|
||||
| undefined
|
||||
if (err) useDialogService().showExecutionErrorDialog(err)
|
||||
}
|
||||
|
||||
// This is very magical only because it matches the respective backend implementation
|
||||
// There is or will be a better way to do this
|
||||
const addOutputLoaderNode = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
|
||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||
let widgetName: 'image' | 'file' | 'audio' | null = null
|
||||
if (result.isImage) {
|
||||
nodeType = 'LoadImage'
|
||||
widgetName = 'image'
|
||||
} else if (result.isVideo) {
|
||||
nodeType = 'LoadVideo'
|
||||
widgetName = 'file'
|
||||
} else if (result.isAudio) {
|
||||
nodeType = 'LoadAudio'
|
||||
widgetName = 'audio'
|
||||
}
|
||||
if (!nodeType || !widgetName) return
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
|
||||
if (!node) return
|
||||
|
||||
const isResultItemType = (v: string | undefined): v is ResultItemType =>
|
||||
v === 'input' || v === 'output' || v === 'temp'
|
||||
|
||||
const apiItem: ResultItem = {
|
||||
filename: result.filename,
|
||||
subfolder: result.subfolder,
|
||||
type: isResultItemType(result.type) ? result.type : undefined
|
||||
}
|
||||
|
||||
const annotated = createAnnotatedPath(apiItem, {
|
||||
rootFolder: apiItem.type
|
||||
})
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) {
|
||||
widget.value = annotated
|
||||
widget.callback?.(annotated)
|
||||
}
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
downloadFile(result.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = item.taskRef?.workflow
|
||||
if (!data) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let filename = `Job ${item.id}.json`
|
||||
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await useDialogService().prompt({
|
||||
title: t('workflowService.exportWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: filename
|
||||
})
|
||||
if (!input) return
|
||||
filename = appendJsonExt(input)
|
||||
}
|
||||
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
const deleteJobAsset = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const task = item.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
if (!task || !preview) return
|
||||
|
||||
const asset = mapTaskOutputToAssetItem(task, preview)
|
||||
const success = await mediaAssetActions.confirmDelete(asset)
|
||||
if (success) {
|
||||
await queueStore.update()
|
||||
}
|
||||
}
|
||||
|
||||
const removeFailedJob = async () => {
|
||||
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) return
|
||||
await queueStore.delete(task)
|
||||
}
|
||||
|
||||
const jobMenuOpenWorkflowLabel = computed(() =>
|
||||
st('queue.jobMenu.openAsWorkflowNewTab', 'Open as workflow in new tab')
|
||||
)
|
||||
const jobMenuOpenWorkflowFailedLabel = computed(() =>
|
||||
st('queue.jobMenu.openWorkflowNewTab', 'Open workflow in new tab')
|
||||
)
|
||||
const jobMenuCopyJobIdLabel = computed(() =>
|
||||
st('queue.jobMenu.copyJobId', 'Copy job ID')
|
||||
)
|
||||
const jobMenuCancelLabel = computed(() =>
|
||||
st('queue.jobMenu.cancelJob', 'Cancel job')
|
||||
)
|
||||
|
||||
const jobMenuEntries = computed<MenuEntry[]>(() => {
|
||||
const item = currentMenuItem()
|
||||
const state = item?.state
|
||||
if (!state) return []
|
||||
const hasDeletableAsset = !!item?.taskRef?.previewOutput
|
||||
if (state === 'completed') {
|
||||
return [
|
||||
{
|
||||
key: 'inspect-asset',
|
||||
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const item = currentMenuItem()
|
||||
if (item) onInspectAsset(item)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
{
|
||||
key: 'add-to-current',
|
||||
label: st(
|
||||
'queue.jobMenu.addToCurrentWorkflow',
|
||||
'Add to current workflow'
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
onClick: addOutputLoaderNode
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
onClick: downloadPreviewAsset
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{
|
||||
key: 'export-workflow',
|
||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||
icon: 'icon-[comfy--file-output]',
|
||||
onClick: exportJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasDeletableAsset
|
||||
? [
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: deleteJobAsset
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return [
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
},
|
||||
{
|
||||
key: 'copy-error',
|
||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyErrorMessage
|
||||
},
|
||||
{
|
||||
key: 'report-error',
|
||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||
icon: 'icon-[lucide--message-circle-warning]',
|
||||
onClick: reportError
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: removeFailedJob
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'cancel-job',
|
||||
label: jobMenuCancelLabel.value,
|
||||
icon: 'icon-[lucide--x]',
|
||||
onClick: cancelJob
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
jobMenuEntries,
|
||||
openJobWorkflow,
|
||||
copyJobId,
|
||||
cancelJob
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user