mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 07:30:11 +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:
116
src/composables/queue/useCompletionSummary.ts
Normal file
116
src/composables/queue/useCompletionSummary.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
export type CompletionSummaryMode = 'allSuccess' | 'mixed' | 'allFailed'
|
||||
|
||||
export type CompletionSummary = {
|
||||
mode: CompletionSummaryMode
|
||||
completedCount: number
|
||||
failedCount: number
|
||||
thumbnailUrls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks queue activity transitions and exposes a short-lived summary of the
|
||||
* most recent generation batch.
|
||||
*/
|
||||
export const useCompletionSummary = () => {
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const isActive = computed(
|
||||
() => queueStore.runningTasks.length > 0 || !executionStore.isIdle
|
||||
)
|
||||
|
||||
const lastActiveStartTs = ref<number | null>(null)
|
||||
const _summary = ref<CompletionSummary | null>(null)
|
||||
const dismissTimer = ref<number | null>(null)
|
||||
|
||||
const clearDismissTimer = () => {
|
||||
if (dismissTimer.value !== null) {
|
||||
clearTimeout(dismissTimer.value)
|
||||
dismissTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const startDismissTimer = () => {
|
||||
clearDismissTimer()
|
||||
dismissTimer.value = window.setTimeout(() => {
|
||||
_summary.value = null
|
||||
dismissTimer.value = null
|
||||
}, 6000)
|
||||
}
|
||||
|
||||
const clearSummary = () => {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
}
|
||||
|
||||
watch(
|
||||
isActive,
|
||||
(active, prev) => {
|
||||
if (!prev && active) {
|
||||
lastActiveStartTs.value = Date.now()
|
||||
}
|
||||
if (prev && !active) {
|
||||
const start = lastActiveStartTs.value ?? 0
|
||||
const finished = queueStore.historyTasks.filter((t: any) => {
|
||||
const ts: number | undefined = t.executionEndTimestamp
|
||||
return typeof ts === 'number' && ts >= start
|
||||
})
|
||||
|
||||
if (!finished.length) {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
return
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let failedCount = 0
|
||||
const imagePreviews: string[] = []
|
||||
|
||||
for (const task of finished) {
|
||||
const state = jobStateFromTask(task, false)
|
||||
if (state === 'completed') {
|
||||
completedCount++
|
||||
const preview = task.previewOutput
|
||||
if (preview?.isImage) {
|
||||
imagePreviews.push(preview.urlWithTimestamp)
|
||||
}
|
||||
} else if (state === 'failed') {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (completedCount === 0 && failedCount === 0) {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
return
|
||||
}
|
||||
|
||||
let mode: CompletionSummaryMode = 'mixed'
|
||||
if (failedCount === 0) mode = 'allSuccess'
|
||||
else if (completedCount === 0) mode = 'allFailed'
|
||||
|
||||
_summary.value = {
|
||||
mode,
|
||||
completedCount,
|
||||
failedCount,
|
||||
thumbnailUrls: imagePreviews.slice(0, 3)
|
||||
}
|
||||
startDismissTimer()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const summary = computed(() => _summary.value)
|
||||
|
||||
return {
|
||||
summary,
|
||||
clearSummary
|
||||
}
|
||||
}
|
||||
352
src/composables/queue/useJobList.ts
Normal file
352
src/composables/queue/useJobList.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import {
|
||||
dateKey,
|
||||
formatClockTime,
|
||||
formatShortMonthDay,
|
||||
isToday,
|
||||
isYesterday
|
||||
} from '@/utils/dateTimeUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
/** Tabs for job list filtering */
|
||||
export const jobTabs = ['All', 'Completed', 'Failed'] as const
|
||||
export type JobTab = (typeof jobTabs)[number]
|
||||
|
||||
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
|
||||
export type JobSortMode = (typeof jobSortModes)[number]
|
||||
|
||||
/**
|
||||
* UI item in the job list. Mirrors data previously prepared inline.
|
||||
*/
|
||||
export type JobListItem = {
|
||||
id: string
|
||||
title: string
|
||||
meta: string
|
||||
state: JobState
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
taskRef?: any
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
runningNodeName?: string
|
||||
executionTimeMs?: number
|
||||
computeHours?: number
|
||||
}
|
||||
|
||||
export type JobGroup = {
|
||||
key: string
|
||||
label: string
|
||||
items: JobListItem[]
|
||||
}
|
||||
|
||||
const ADDED_HINT_DURATION_MS = 3000
|
||||
const relativeTimeFormatterCache = new Map<string, Intl.RelativeTimeFormat>()
|
||||
const taskIdToKey = (id: string | number | undefined) => {
|
||||
if (id === null || id === undefined) return null
|
||||
const key = String(id)
|
||||
return key.length ? key : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns localized Today/Yesterday (capitalized) or localized Mon DD.
|
||||
*/
|
||||
const dateLabelForTimestamp = (
|
||||
ts: number,
|
||||
locale: string,
|
||||
relativeFormatter: Intl.RelativeTimeFormat
|
||||
) => {
|
||||
const formatRelativeDay = (value: number) => {
|
||||
const formatted = relativeFormatter.format(value, 'day')
|
||||
return formatted
|
||||
? formatted[0].toLocaleUpperCase(locale) + formatted.slice(1)
|
||||
: formatted
|
||||
}
|
||||
if (isToday(ts)) {
|
||||
return formatRelativeDay(0)
|
||||
}
|
||||
if (isYesterday(ts)) {
|
||||
return formatRelativeDay(-1)
|
||||
}
|
||||
return formatShortMonthDay(ts, locale)
|
||||
}
|
||||
|
||||
type TaskWithState = {
|
||||
task: TaskItemImpl
|
||||
state: JobState
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the reactive job list, filters, and grouped view for the queue overlay.
|
||||
*/
|
||||
export function useJobList() {
|
||||
const { t, locale } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
|
||||
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const clearAddedHintTimeout = (id: string) => {
|
||||
const timeoutId = addedHintTimeouts.get(id)
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
addedHintTimeouts.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleAddedHintExpiry = (id: string) => {
|
||||
clearAddedHintTimeout(id)
|
||||
const timeoutId = setTimeout(() => {
|
||||
addedHintTimeouts.delete(id)
|
||||
const updated = new Set(recentlyAddedPendingIds.value)
|
||||
if (updated.delete(id)) {
|
||||
recentlyAddedPendingIds.value = updated
|
||||
}
|
||||
}, ADDED_HINT_DURATION_MS)
|
||||
addedHintTimeouts.set(id, timeoutId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
queueStore.pendingTasks
|
||||
.map((task) => taskIdToKey(task.promptId))
|
||||
.filter((id): id is string => !!id),
|
||||
(pendingIds) => {
|
||||
const pendingSet = new Set(pendingIds)
|
||||
const next = new Set(recentlyAddedPendingIds.value)
|
||||
|
||||
pendingIds.forEach((id) => {
|
||||
if (!next.has(id)) {
|
||||
next.add(id)
|
||||
scheduleAddedHintExpiry(id)
|
||||
}
|
||||
})
|
||||
|
||||
for (const id of Array.from(next)) {
|
||||
if (!pendingSet.has(id)) {
|
||||
next.delete(id)
|
||||
clearAddedHintTimeout(id)
|
||||
}
|
||||
}
|
||||
|
||||
recentlyAddedPendingIds.value = next
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const shouldShowAddedHint = (task: TaskItemImpl, state: JobState) => {
|
||||
if (state !== 'pending') return false
|
||||
const id = taskIdToKey(task.promptId)
|
||||
if (!id) return false
|
||||
return recentlyAddedPendingIds.value.has(id)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
|
||||
addedHintTimeouts.clear()
|
||||
recentlyAddedPendingIds.value = new Set<string>()
|
||||
})
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
|
||||
const relativeTimeFormatter = computed(() => {
|
||||
const localeValue = locale.value
|
||||
let formatter = relativeTimeFormatterCache.get(localeValue)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.RelativeTimeFormat(localeValue, { numeric: 'auto' })
|
||||
relativeTimeFormatterCache.set(localeValue, formatter)
|
||||
}
|
||||
return formatter
|
||||
})
|
||||
const undatedLabel = computed(() => t('queue.jobList.undated') || 'Undated')
|
||||
|
||||
const isJobInitializing = (promptId: string | number | undefined) =>
|
||||
executionStore.isPromptInitializing(promptId)
|
||||
|
||||
const currentNodeName = computed(() => {
|
||||
const node = executionStore.executingNode
|
||||
if (!node) return t('g.emDash')
|
||||
const title = (node.title ?? '').toString().trim()
|
||||
if (title) return title
|
||||
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
|
||||
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
||||
return st(key, nodeType)
|
||||
})
|
||||
|
||||
const selectedJobTab = ref<JobTab>('All')
|
||||
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
|
||||
const selectedSortMode = ref<JobSortMode>('mostRecent')
|
||||
|
||||
const allTasksSorted = computed<TaskItemImpl[]>(() => {
|
||||
const all = [
|
||||
...queueStore.pendingTasks,
|
||||
...queueStore.runningTasks,
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
return all.sort((a, b) => b.queueIndex - a.queueIndex)
|
||||
})
|
||||
|
||||
const tasksWithJobState = computed<TaskWithState[]>(() =>
|
||||
allTasksSorted.value.map((task) => ({
|
||||
task,
|
||||
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
|
||||
}))
|
||||
)
|
||||
|
||||
const hasFailedJobs = computed(() =>
|
||||
tasksWithJobState.value.some(({ state }) => state === 'failed')
|
||||
)
|
||||
|
||||
watch(
|
||||
() => hasFailedJobs.value,
|
||||
(hasFailed) => {
|
||||
if (!hasFailed && selectedJobTab.value === 'Failed') {
|
||||
selectedJobTab.value = 'All'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const filteredTaskEntries = computed<TaskWithState[]>(() => {
|
||||
let entries = tasksWithJobState.value
|
||||
if (selectedJobTab.value === 'Completed') {
|
||||
entries = entries.filter(({ state }) => state === 'completed')
|
||||
} else if (selectedJobTab.value === 'Failed') {
|
||||
entries = entries.filter(({ state }) => state === 'failed')
|
||||
}
|
||||
|
||||
if (selectedWorkflowFilter.value === 'current') {
|
||||
const activeId = workflowStore.activeWorkflow?.activeState?.id
|
||||
if (!activeId) return []
|
||||
entries = entries.filter(({ task }) => {
|
||||
const wid = task.workflow?.id
|
||||
return !!wid && wid === activeId
|
||||
})
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
const filteredTasks = computed<TaskItemImpl[]>(() =>
|
||||
filteredTaskEntries.value.map(({ task }) => task)
|
||||
)
|
||||
|
||||
const jobItems = computed<JobListItem[]>(() => {
|
||||
return filteredTaskEntries.value.map(({ task, state }) => {
|
||||
const isActive =
|
||||
String(task.promptId ?? '') ===
|
||||
String(executionStore.activePromptId ?? '')
|
||||
const showAddedHint = shouldShowAddedHint(task, state)
|
||||
|
||||
const display = buildJobDisplay(task, state, {
|
||||
t,
|
||||
locale: locale.value,
|
||||
formatClockTimeFn: formatClockTime,
|
||||
isActive,
|
||||
totalPercent: isActive ? totalPercent.value : undefined,
|
||||
currentNodePercent: isActive ? currentNodePercent.value : undefined,
|
||||
currentNodeName: isActive ? currentNodeName.value : undefined,
|
||||
showAddedHint
|
||||
})
|
||||
|
||||
return {
|
||||
id: String(task.promptId),
|
||||
title: display.primary,
|
||||
meta: display.secondary,
|
||||
state,
|
||||
iconName: display.iconName,
|
||||
iconImageUrl: display.iconImageUrl,
|
||||
showClear: display.showClear,
|
||||
taskRef: task,
|
||||
progressTotalPercent:
|
||||
state === 'running' && isActive ? totalPercent.value : undefined,
|
||||
progressCurrentPercent:
|
||||
state === 'running' && isActive
|
||||
? currentNodePercent.value
|
||||
: undefined,
|
||||
runningNodeName:
|
||||
state === 'running' && isActive ? currentNodeName.value : undefined,
|
||||
executionTimeMs: task.executionTime,
|
||||
computeHours:
|
||||
task.executionTime !== undefined
|
||||
? task.executionTime / 3_600_000
|
||||
: undefined
|
||||
} as JobListItem
|
||||
})
|
||||
})
|
||||
|
||||
const jobItemById = computed(() => {
|
||||
const m = new Map<string, JobListItem>()
|
||||
jobItems.value.forEach((ji) => m.set(ji.id, ji))
|
||||
return m
|
||||
})
|
||||
|
||||
const groupedJobItems = computed<JobGroup[]>(() => {
|
||||
const groups: JobGroup[] = []
|
||||
const index = new Map<string, number>()
|
||||
const localeValue = locale.value
|
||||
for (const { task, state } of filteredTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
ts = task.executionEndTimestamp
|
||||
} else {
|
||||
ts = task.createTime
|
||||
}
|
||||
const key = ts === undefined ? 'undated' : dateKey(ts)
|
||||
let groupIdx = index.get(key)
|
||||
if (groupIdx === undefined) {
|
||||
const label =
|
||||
ts === undefined
|
||||
? undatedLabel.value
|
||||
: dateLabelForTimestamp(
|
||||
ts,
|
||||
localeValue,
|
||||
relativeTimeFormatter.value
|
||||
)
|
||||
groups.push({ key, label, items: [] })
|
||||
groupIdx = groups.length - 1
|
||||
index.set(key, groupIdx)
|
||||
}
|
||||
const ji = jobItemById.value.get(String(task.promptId))
|
||||
if (ji) groups[groupIdx].items.push(ji)
|
||||
}
|
||||
|
||||
if (selectedSortMode.value === 'totalGenerationTime') {
|
||||
const valueOrDefault = (value: JobListItem['executionTimeMs']) =>
|
||||
typeof value === 'number' && !Number.isNaN(value) ? value : -1
|
||||
const sortByExecutionTimeDesc = (a: JobListItem, b: JobListItem) =>
|
||||
valueOrDefault(b.executionTimeMs) - valueOrDefault(a.executionTimeMs)
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.items.sort(sortByExecutionTimeDesc)
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
return {
|
||||
// filters/state
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
// data sources
|
||||
allTasksSorted,
|
||||
filteredTasks,
|
||||
jobItems,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/composables/queue/useQueueProgress.ts
Normal file
50
src/composables/queue/useQueueProgress.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
|
||||
|
||||
/**
|
||||
* Queue progress composable exposing total/current node progress values and styles.
|
||||
*/
|
||||
export function useQueueProgress() {
|
||||
const { locale } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const totalPercent = computed(() =>
|
||||
clampPercentInt(Math.round((executionStore.executionProgress ?? 0) * 100))
|
||||
)
|
||||
|
||||
const totalPercentFormatted = computed(() =>
|
||||
formatPercent0(locale.value, totalPercent.value)
|
||||
)
|
||||
|
||||
const currentNodePercent = computed(() =>
|
||||
clampPercentInt(
|
||||
Math.round((executionStore.executingNodeProgress ?? 0) * 100)
|
||||
)
|
||||
)
|
||||
|
||||
const currentNodePercentFormatted = computed(() =>
|
||||
formatPercent0(locale.value, currentNodePercent.value)
|
||||
)
|
||||
|
||||
const totalProgressStyle = computed(() => ({
|
||||
width: `${totalPercent.value}%`,
|
||||
background: 'var(--color-interface-panel-job-progress-primary)'
|
||||
}))
|
||||
|
||||
const currentNodeProgressStyle = computed(() => ({
|
||||
width: `${currentNodePercent.value}%`,
|
||||
background: 'var(--color-interface-panel-job-progress-secondary)'
|
||||
}))
|
||||
|
||||
return {
|
||||
totalPercent,
|
||||
totalPercentFormatted,
|
||||
currentNodePercent,
|
||||
currentNodePercentFormatted,
|
||||
totalProgressStyle,
|
||||
currentNodeProgressStyle
|
||||
}
|
||||
}
|
||||
32
src/composables/queue/useResultGallery.ts
Normal file
32
src/composables/queue/useResultGallery.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* Manages result gallery state and activation for queue items.
|
||||
*/
|
||||
export function useResultGallery(getFilteredTasks: () => any[]) {
|
||||
const galleryActiveIndex = ref(-1)
|
||||
const galleryItems = shallowRef<ResultItemImpl[]>([])
|
||||
|
||||
const onViewItem = (item: JobListItem) => {
|
||||
const items: ResultItemImpl[] = getFilteredTasks().flatMap((t: any) => {
|
||||
const preview = t.previewOutput
|
||||
return preview && preview.supportsPreview ? [preview] : []
|
||||
})
|
||||
|
||||
if (!items.length) return
|
||||
|
||||
galleryItems.value = items
|
||||
const activeUrl: string | undefined = item.taskRef?.previewOutput?.url
|
||||
const idx = activeUrl ? items.findIndex((o) => o.url === activeUrl) : 0
|
||||
galleryActiveIndex.value = idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
return {
|
||||
galleryActiveIndex,
|
||||
galleryItems,
|
||||
onViewItem
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user