mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 06:19:58 +00:00
## Summary - Add `errorMessage` and `executionError` getters to `TaskItemImpl` that extract error info from status messages - Update `useJobErrorReporting` composable to use these getters instead of standalone function - Remove the standalone `extractExecutionError` function This encapsulates error extraction within `TaskItemImpl`, preparing for the Jobs API migration where the underlying data format will change but the getter interface will remain stable. ## Test plan - [x] All existing tests pass - [x] New tests added for `TaskItemImpl.errorMessage` and `TaskItemImpl.executionError` getters - [x] TypeScript, lint, and knip checks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7650-refactor-encapsulate-error-extraction-in-TaskItemImpl-getters-2ce6d73d365081caae33dcc7e1e07720) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org>
619 lines
15 KiB
TypeScript
619 lines
15 KiB
TypeScript
import _ from 'es-toolkit/compat'
|
|
import { defineStore } from 'pinia'
|
|
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
|
|
|
|
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
|
import type {
|
|
APITaskType,
|
|
JobListItem,
|
|
TaskType
|
|
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
|
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import type {
|
|
ResultItem,
|
|
StatusWsMessageStatus,
|
|
TaskOutput
|
|
} from '@/schemas/apiSchema'
|
|
import { api } from '@/scripts/api'
|
|
import type { ComfyApp } from '@/scripts/app'
|
|
import { useExtensionService } from '@/services/extensionService'
|
|
import { getJobDetail } from '@/services/jobOutputCache'
|
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
|
import { useExecutionStore } from '@/stores/executionStore'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
|
|
|
enum TaskItemDisplayStatus {
|
|
Running = 'Running',
|
|
Pending = 'Pending',
|
|
Completed = 'Completed',
|
|
Failed = 'Failed',
|
|
Cancelled = 'Cancelled'
|
|
}
|
|
|
|
export class ResultItemImpl {
|
|
filename: string
|
|
subfolder: string
|
|
type: string
|
|
|
|
nodeId: NodeId
|
|
// 'audio' | 'images' | ...
|
|
mediaType: string
|
|
|
|
// VHS output specific fields
|
|
format?: string
|
|
frame_rate?: number
|
|
|
|
constructor(obj: Record<string, any>) {
|
|
this.filename = obj.filename ?? ''
|
|
this.subfolder = obj.subfolder ?? ''
|
|
this.type = obj.type ?? ''
|
|
|
|
this.nodeId = obj.nodeId
|
|
this.mediaType = obj.mediaType
|
|
|
|
this.format = obj.format
|
|
this.frame_rate = obj.frame_rate
|
|
}
|
|
|
|
get urlParams(): URLSearchParams {
|
|
const params = new URLSearchParams()
|
|
params.set('filename', this.filename)
|
|
params.set('type', this.type)
|
|
params.set('subfolder', this.subfolder)
|
|
|
|
if (this.format) {
|
|
params.set('format', this.format)
|
|
}
|
|
if (this.frame_rate) {
|
|
params.set('frame_rate', this.frame_rate.toString())
|
|
}
|
|
return params
|
|
}
|
|
|
|
/**
|
|
* VHS advanced preview URL. `/viewvideo` endpoint is provided by VHS node.
|
|
*
|
|
* `/viewvideo` always returns a webm file.
|
|
*/
|
|
get vhsAdvancedPreviewUrl(): string {
|
|
return api.apiURL('/viewvideo?' + this.urlParams)
|
|
}
|
|
|
|
get url(): string {
|
|
return api.apiURL('/view?' + this.urlParams)
|
|
}
|
|
|
|
get urlWithTimestamp(): string {
|
|
return `${this.url}&t=${+new Date()}`
|
|
}
|
|
|
|
get isVhsFormat(): boolean {
|
|
return !!this.format && !!this.frame_rate
|
|
}
|
|
|
|
get htmlVideoType(): string | undefined {
|
|
if (this.isWebm) {
|
|
return 'video/webm'
|
|
}
|
|
if (this.isMp4) {
|
|
return 'video/mp4'
|
|
}
|
|
|
|
if (this.isVhsFormat) {
|
|
if (this.format?.endsWith('webm')) {
|
|
return 'video/webm'
|
|
}
|
|
if (this.format?.endsWith('mp4')) {
|
|
return 'video/mp4'
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
get htmlAudioType(): string | undefined {
|
|
if (this.isMp3) {
|
|
return 'audio/mpeg'
|
|
}
|
|
if (this.isWav) {
|
|
return 'audio/wav'
|
|
}
|
|
if (this.isOgg) {
|
|
return 'audio/ogg'
|
|
}
|
|
if (this.isFlac) {
|
|
return 'audio/flac'
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
get isGif(): boolean {
|
|
return this.filename.endsWith('.gif')
|
|
}
|
|
|
|
get isWebp(): boolean {
|
|
return this.filename.endsWith('.webp')
|
|
}
|
|
|
|
get isWebm(): boolean {
|
|
return this.filename.endsWith('.webm')
|
|
}
|
|
|
|
get isMp4(): boolean {
|
|
return this.filename.endsWith('.mp4')
|
|
}
|
|
|
|
get isVideoBySuffix(): boolean {
|
|
return this.isWebm || this.isMp4
|
|
}
|
|
|
|
get isImageBySuffix(): boolean {
|
|
return this.isGif || this.isWebp
|
|
}
|
|
|
|
get isMp3(): boolean {
|
|
return this.filename.endsWith('.mp3')
|
|
}
|
|
|
|
get isWav(): boolean {
|
|
return this.filename.endsWith('.wav')
|
|
}
|
|
|
|
get isOgg(): boolean {
|
|
return this.filename.endsWith('.ogg')
|
|
}
|
|
|
|
get isFlac(): boolean {
|
|
return this.filename.endsWith('.flac')
|
|
}
|
|
|
|
get isAudioBySuffix(): boolean {
|
|
return this.isMp3 || this.isWav || this.isOgg || this.isFlac
|
|
}
|
|
|
|
get isVideo(): boolean {
|
|
const isVideoByType =
|
|
this.mediaType === 'video' || !!this.format?.startsWith('video/')
|
|
return (
|
|
this.isVideoBySuffix ||
|
|
(isVideoByType && !this.isImageBySuffix && !this.isAudioBySuffix)
|
|
)
|
|
}
|
|
|
|
get isImage(): boolean {
|
|
return (
|
|
this.isImageBySuffix ||
|
|
(this.mediaType === 'images' &&
|
|
!this.isVideoBySuffix &&
|
|
!this.isAudioBySuffix)
|
|
)
|
|
}
|
|
|
|
get isAudio(): boolean {
|
|
const isAudioByType =
|
|
this.mediaType === 'audio' || !!this.format?.startsWith('audio/')
|
|
return (
|
|
this.isAudioBySuffix ||
|
|
(isAudioByType && !this.isImageBySuffix && !this.isVideoBySuffix)
|
|
)
|
|
}
|
|
|
|
get is3D(): boolean {
|
|
return getMediaTypeFromFilename(this.filename) === '3D'
|
|
}
|
|
|
|
get supportsPreview(): boolean {
|
|
return this.isImage || this.isVideo || this.isAudio || this.is3D
|
|
}
|
|
|
|
static filterPreviewable(
|
|
outputs: readonly ResultItemImpl[]
|
|
): ResultItemImpl[] {
|
|
return outputs.filter((o) => o.supportsPreview)
|
|
}
|
|
|
|
static findByUrl(items: readonly ResultItemImpl[], url?: string): number {
|
|
if (!url) return 0
|
|
const idx = items.findIndex((o) => o.url === url)
|
|
return idx >= 0 ? idx : 0
|
|
}
|
|
}
|
|
|
|
export class TaskItemImpl {
|
|
readonly job: JobListItem
|
|
readonly outputs: TaskOutput
|
|
readonly flatOutputs: ReadonlyArray<ResultItemImpl>
|
|
|
|
constructor(
|
|
job: JobListItem,
|
|
outputs?: TaskOutput,
|
|
flatOutputs?: ReadonlyArray<ResultItemImpl>
|
|
) {
|
|
this.job = job
|
|
// If no outputs provided but job has preview_output, create synthetic outputs
|
|
// using the real nodeId and mediaType from the backend response
|
|
const effectiveOutputs =
|
|
outputs ??
|
|
(job.preview_output
|
|
? {
|
|
[job.preview_output.nodeId]: {
|
|
[job.preview_output.mediaType]: [job.preview_output]
|
|
}
|
|
}
|
|
: {})
|
|
// Remove animated outputs from the outputs object
|
|
this.outputs = _.mapValues(effectiveOutputs, (nodeOutputs) =>
|
|
_.omit(nodeOutputs, 'animated')
|
|
)
|
|
this.flatOutputs = flatOutputs ?? this.calculateFlatOutputs()
|
|
}
|
|
|
|
calculateFlatOutputs(): ReadonlyArray<ResultItemImpl> {
|
|
if (!this.outputs) {
|
|
return []
|
|
}
|
|
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
|
|
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
|
|
(items as ResultItem[]).map(
|
|
(item: ResultItem) =>
|
|
new ResultItemImpl({
|
|
...item,
|
|
nodeId,
|
|
mediaType
|
|
})
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
/** All outputs that support preview (images, videos, audio, 3D) */
|
|
get previewableOutputs(): readonly ResultItemImpl[] {
|
|
return ResultItemImpl.filterPreviewable(this.flatOutputs)
|
|
}
|
|
|
|
get previewOutput(): ResultItemImpl | undefined {
|
|
const previewable = this.previewableOutputs
|
|
// Prefer saved media files over the temp previews
|
|
return (
|
|
previewable.find((output) => output.type === 'output') ?? previewable[0]
|
|
)
|
|
}
|
|
|
|
// Derive taskType from job status
|
|
get taskType(): TaskType {
|
|
switch (this.job.status) {
|
|
case 'in_progress':
|
|
return 'Running'
|
|
case 'pending':
|
|
return 'Pending'
|
|
default:
|
|
return 'History'
|
|
}
|
|
}
|
|
|
|
get apiTaskType(): APITaskType {
|
|
switch (this.taskType) {
|
|
case 'Running':
|
|
case 'Pending':
|
|
return 'queue'
|
|
case 'History':
|
|
return 'history'
|
|
}
|
|
}
|
|
|
|
get key() {
|
|
return this.promptId + this.displayStatus
|
|
}
|
|
|
|
get queueIndex() {
|
|
return this.job.priority
|
|
}
|
|
|
|
get promptId() {
|
|
return this.job.id
|
|
}
|
|
|
|
get outputsCount(): number | undefined {
|
|
return this.job.outputs_count ?? undefined
|
|
}
|
|
|
|
get status() {
|
|
return this.job.status
|
|
}
|
|
|
|
get errorMessage(): string | undefined {
|
|
return this.job.execution_error?.exception_message ?? undefined
|
|
}
|
|
|
|
get executionError() {
|
|
return this.job.execution_error ?? undefined
|
|
}
|
|
|
|
get workflowId(): string | undefined {
|
|
return this.job.workflow_id ?? undefined
|
|
}
|
|
|
|
get createTime(): number {
|
|
return this.job.create_time
|
|
}
|
|
|
|
get interrupted(): boolean {
|
|
return (
|
|
this.job.status === 'failed' &&
|
|
this.job.execution_error?.exception_type ===
|
|
'InterruptProcessingException'
|
|
)
|
|
}
|
|
|
|
get isHistory() {
|
|
return this.taskType === 'History'
|
|
}
|
|
|
|
get isRunning() {
|
|
return this.taskType === 'Running'
|
|
}
|
|
|
|
get displayStatus(): TaskItemDisplayStatus {
|
|
switch (this.job.status) {
|
|
case 'in_progress':
|
|
return TaskItemDisplayStatus.Running
|
|
case 'pending':
|
|
return TaskItemDisplayStatus.Pending
|
|
case 'completed':
|
|
return TaskItemDisplayStatus.Completed
|
|
case 'failed':
|
|
return TaskItemDisplayStatus.Failed
|
|
case 'cancelled':
|
|
return TaskItemDisplayStatus.Cancelled
|
|
}
|
|
}
|
|
|
|
get executionStartTimestamp() {
|
|
return this.job.execution_start_time ?? undefined
|
|
}
|
|
|
|
get executionEndTimestamp() {
|
|
return this.job.execution_end_time ?? undefined
|
|
}
|
|
|
|
get executionTime() {
|
|
if (!this.executionStartTimestamp || !this.executionEndTimestamp) {
|
|
return undefined
|
|
}
|
|
return this.executionEndTimestamp - this.executionStartTimestamp
|
|
}
|
|
|
|
get executionTimeInSeconds() {
|
|
return this.executionTime !== undefined
|
|
? this.executionTime / 1000
|
|
: undefined
|
|
}
|
|
|
|
/**
|
|
* Loads full outputs for tasks that only have preview data
|
|
* Returns a new TaskItemImpl with full outputs and execution status
|
|
*/
|
|
public async loadFullOutputs(): Promise<TaskItemImpl> {
|
|
// Only load for history tasks (caller checks outputsCount > 1)
|
|
if (!this.isHistory) {
|
|
return this
|
|
}
|
|
const jobDetail = await getJobDetail(this.promptId)
|
|
|
|
if (!jobDetail?.outputs) {
|
|
return this
|
|
}
|
|
|
|
// Create new TaskItemImpl with full outputs
|
|
return new TaskItemImpl(this.job, jobDetail.outputs)
|
|
}
|
|
|
|
public async loadWorkflow(app: ComfyApp) {
|
|
if (!this.isHistory) {
|
|
return
|
|
}
|
|
|
|
// Single fetch for both workflow and outputs (with caching)
|
|
const jobDetail = await getJobDetail(this.promptId)
|
|
|
|
const workflowData = await extractWorkflow(jobDetail)
|
|
if (!workflowData) {
|
|
return
|
|
}
|
|
|
|
await app.loadGraphData(toRaw(workflowData))
|
|
|
|
// Use full outputs from job detail, or fall back to existing outputs
|
|
const outputsToLoad = jobDetail?.outputs ?? this.outputs
|
|
if (!outputsToLoad) {
|
|
return
|
|
}
|
|
|
|
const nodeOutputsStore = useNodeOutputStore()
|
|
const rawOutputs = toRaw(outputsToLoad)
|
|
for (const nodeExecutionId in rawOutputs) {
|
|
nodeOutputsStore.setNodeOutputsByExecutionId(
|
|
nodeExecutionId,
|
|
rawOutputs[nodeExecutionId]
|
|
)
|
|
}
|
|
useExtensionService().invokeExtensions(
|
|
'onNodeOutputsUpdated',
|
|
app.nodeOutputs
|
|
)
|
|
}
|
|
|
|
public flatten(): TaskItemImpl[] {
|
|
if (this.displayStatus !== TaskItemDisplayStatus.Completed) {
|
|
return [this]
|
|
}
|
|
|
|
return this.flatOutputs.map(
|
|
(output: ResultItemImpl, i: number) =>
|
|
new TaskItemImpl(
|
|
{
|
|
...this.job,
|
|
id: `${this.promptId}-${i}`
|
|
},
|
|
{
|
|
[output.nodeId]: {
|
|
[output.mediaType]: [output]
|
|
}
|
|
},
|
|
[output]
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
export const useQueueStore = defineStore('queue', () => {
|
|
// Use shallowRef because TaskItemImpl instances are immutable and arrays are
|
|
// replaced entirely (not mutated), so deep reactivity would waste performance
|
|
const runningTasks = shallowRef<TaskItemImpl[]>([])
|
|
const pendingTasks = shallowRef<TaskItemImpl[]>([])
|
|
const historyTasks = shallowRef<TaskItemImpl[]>([])
|
|
const maxHistoryItems = ref(64)
|
|
const isLoading = ref(false)
|
|
|
|
const tasks = computed<TaskItemImpl[]>(
|
|
() =>
|
|
[
|
|
...pendingTasks.value,
|
|
...runningTasks.value,
|
|
...historyTasks.value
|
|
] as TaskItemImpl[]
|
|
)
|
|
|
|
const flatTasks = computed<TaskItemImpl[]>(() =>
|
|
tasks.value.flatMap((task: TaskItemImpl) => task.flatten())
|
|
)
|
|
|
|
const lastHistoryQueueIndex = computed<number>(() =>
|
|
historyTasks.value.length ? historyTasks.value[0].queueIndex : -1
|
|
)
|
|
|
|
const hasPendingTasks = computed<boolean>(() => pendingTasks.value.length > 0)
|
|
|
|
const update = async () => {
|
|
isLoading.value = true
|
|
try {
|
|
const [queue, history] = await Promise.all([
|
|
api.getQueue(),
|
|
api.getHistory(maxHistoryItems.value)
|
|
])
|
|
|
|
// API returns pre-sorted data (sort_by=create_time&order=desc)
|
|
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
|
|
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
|
|
|
|
const currentHistory = toValue(historyTasks)
|
|
|
|
const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
|
|
const executionStore = useExecutionStore()
|
|
appearedTasks.forEach((task) => {
|
|
const promptIdString = String(task.promptId)
|
|
const workflowId = task.workflowId
|
|
if (workflowId && promptIdString) {
|
|
executionStore.registerPromptWorkflowIdMapping(
|
|
promptIdString,
|
|
workflowId
|
|
)
|
|
}
|
|
})
|
|
|
|
// Sort by create_time descending and limit to maxItems
|
|
const sortedHistory = [...history]
|
|
.sort((a, b) => b.create_time - a.create_time)
|
|
.slice(0, toValue(maxHistoryItems))
|
|
|
|
// Reuse existing TaskItemImpl instances or create new
|
|
// Must recreate if outputs_count changed (e.g., API started returning it)
|
|
const existingByPromptId = new Map(
|
|
currentHistory.map((impl) => [impl.promptId, impl])
|
|
)
|
|
|
|
historyTasks.value = sortedHistory.map((job) => {
|
|
const existing = existingByPromptId.get(job.id)
|
|
if (!existing) return new TaskItemImpl(job)
|
|
// Recreate if outputs_count changed to ensure lazy loading works
|
|
if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
|
|
return new TaskItemImpl(job)
|
|
}
|
|
return existing
|
|
})
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
const clear = async (
|
|
targets: ('queue' | 'history')[] = ['queue', 'history']
|
|
) => {
|
|
if (targets.length === 0) {
|
|
return
|
|
}
|
|
await Promise.all(targets.map((type) => api.clearItems(type)))
|
|
await update()
|
|
}
|
|
|
|
const deleteTask = async (task: TaskItemImpl) => {
|
|
await api.deleteItem(task.apiTaskType, task.promptId)
|
|
await update()
|
|
}
|
|
|
|
return {
|
|
runningTasks,
|
|
pendingTasks,
|
|
historyTasks,
|
|
maxHistoryItems,
|
|
isLoading,
|
|
|
|
tasks,
|
|
flatTasks,
|
|
lastHistoryQueueIndex,
|
|
hasPendingTasks,
|
|
|
|
update,
|
|
clear,
|
|
delete: deleteTask
|
|
}
|
|
})
|
|
|
|
export const useQueuePendingTaskCountStore = defineStore(
|
|
'queuePendingTaskCount',
|
|
{
|
|
state: () => ({
|
|
count: 0
|
|
}),
|
|
actions: {
|
|
update(e: CustomEvent<StatusWsMessageStatus>) {
|
|
this.count = e.detail?.exec_info?.queue_remaining || 0
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
export type AutoQueueMode = 'disabled' | 'instant' | 'change'
|
|
|
|
export const useQueueSettingsStore = defineStore('queueSettingsStore', {
|
|
state: () => ({
|
|
mode: 'disabled' as AutoQueueMode,
|
|
batchCount: 1
|
|
})
|
|
})
|
|
|
|
export const useQueueUIStore = defineStore('queueUIStore', () => {
|
|
const settingStore = useSettingStore()
|
|
|
|
const isOverlayExpanded = computed({
|
|
get: () => settingStore.get('Comfy.Queue.History.Expanded'),
|
|
set: (value) => settingStore.set('Comfy.Queue.History.Expanded', value)
|
|
})
|
|
|
|
function toggleOverlay() {
|
|
isOverlayExpanded.value = !isOverlayExpanded.value
|
|
}
|
|
|
|
return { isOverlayExpanded, toggleOverlay }
|
|
})
|