mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-25 14:57:37 +00:00
Compare commits
22 Commits
pablo_hack
...
concurrent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
134e7f382c | ||
|
|
065d2abe69 | ||
|
|
30e17a2f38 | ||
|
|
6872fd84bc | ||
|
|
a7a6416b23 | ||
|
|
a7d4366760 | ||
|
|
ada2fb2c96 | ||
|
|
bf80131a29 | ||
|
|
325c3021e4 | ||
|
|
982e4a0262 | ||
|
|
eb2100dd21 | ||
|
|
17a175bd84 | ||
|
|
3b100462ce | ||
|
|
34105ac5b3 | ||
|
|
3a3ea0cce4 | ||
|
|
eca084484d | ||
|
|
3c97c978c1 | ||
|
|
53c9c69fdc | ||
|
|
f50bb0840b | ||
|
|
d29a93421f | ||
|
|
ba5c5f7194 | ||
|
|
55a73bcd0b |
@@ -384,6 +384,17 @@ describe('TopMenuSection', () => {
|
||||
configureSettings(pinia, true)
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activeJobId = 'job-1'
|
||||
executionStore.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
|
||||
@@ -32,16 +32,56 @@
|
||||
<Suspense @resolve="comfyRunButtonResolved">
|
||||
<ComfyRunButton />
|
||||
</Suspense>
|
||||
<Button
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<template v-if="showConcurrentControls">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-lg bg-secondary-background px-2 py-1"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ t('menu.nRunning', { count: n(runningCount) }) }}
|
||||
</span>
|
||||
<Button
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="queuedCount > 0"
|
||||
class="flex items-center gap-1 rounded-lg bg-secondary-background px-2 py-1"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ t('menu.nQueued', { count: n(queuedCount) }) }}
|
||||
</span>
|
||||
<Button
|
||||
v-tooltip.bottom="clearQueueTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
"
|
||||
@click="handleClearQueue"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Button
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
variant="secondary"
|
||||
@@ -108,6 +148,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -143,6 +184,14 @@ const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
|
||||
const { isConcurrentExecutionEnabled } = useConcurrentExecution()
|
||||
|
||||
const runningCount = computed(() => executionStore.runningJobIds.length)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const showConcurrentControls = computed(
|
||||
() => isConcurrentExecutionEnabled.value && !executionStore.isIdle
|
||||
)
|
||||
|
||||
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
@@ -371,6 +420,9 @@ watch(isDragging, (dragging) => {
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
const clearQueueTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(
|
||||
t(
|
||||
|
||||
@@ -89,11 +89,12 @@ function createWrapper() {
|
||||
},
|
||||
stubs: {
|
||||
BatchCountEdit: BatchCountEditStub,
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' }
|
||||
DropdownMenu: {
|
||||
template:
|
||||
'<div><slot name="button" /><slot :item-class="\'\'" /></div>'
|
||||
},
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
{{ queueButtonLabel }}
|
||||
</Button>
|
||||
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<DropdownMenu>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
@@ -30,54 +30,82 @@
|
||||
>
|
||||
<TinyChevronIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:side-offset="4"
|
||||
class="z-1000 min-w-44 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
|
||||
</template>
|
||||
<template #default="{ itemClass }">
|
||||
<DropdownMenuItem
|
||||
v-for="item in queueModeMenuItems"
|
||||
:key="item.key"
|
||||
as-child
|
||||
@select.prevent="item.command"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="item in queueModeMenuItems"
|
||||
:key="item.key"
|
||||
as-child
|
||||
@select.prevent="item.command"
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:variant="item.key === selectedQueueMode ? 'primary' : 'secondary'"
|
||||
size="sm"
|
||||
:class="cn(itemClass, queueMenuItemButtonClass)"
|
||||
>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:variant="
|
||||
item.key === selectedQueueMode ? 'primary' : 'secondary'
|
||||
"
|
||||
size="sm"
|
||||
:class="queueMenuItemButtonClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
{{ item.label }}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator
|
||||
v-if="isParallelToggleVisible"
|
||||
class="m-1 h-px bg-border-subtle"
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
v-if="isParallelToggleVisible"
|
||||
:class="itemClass"
|
||||
:disabled="isParallelToggleDisabled"
|
||||
@select.prevent="toggleParallel"
|
||||
>
|
||||
<div class="flex flex-col select-none">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm text-text-primary">{{
|
||||
t('menu.parallelExecution')
|
||||
}}</span>
|
||||
<StatusBadge :label="t('g.new')" class="text-[10px]" />
|
||||
</div>
|
||||
<span class="text-text-muted text-xs">{{
|
||||
t('menu.parallelUpTo', { count: maxConcurrentJobs })
|
||||
}}</span>
|
||||
</div>
|
||||
<SwitchRoot
|
||||
:checked="parallelToggleChecked"
|
||||
:disabled="isParallelToggleDisabled"
|
||||
class="relative ml-auto h-5 w-9 shrink-0 cursor-pointer rounded-full bg-secondary-background transition-colors data-disabled:cursor-not-allowed data-[state=checked]:bg-primary-background"
|
||||
@click.stop
|
||||
@update:checked="onParallelToggle"
|
||||
>
|
||||
<SwitchThumb
|
||||
class="block size-4 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-[18px]"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuSeparator,
|
||||
SwitchRoot,
|
||||
SwitchThumb
|
||||
} from 'reka-ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import BatchCountEdit from '@/components/actionbar/BatchCountEdit.vue'
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import TinyChevronIcon from '@/components/actionbar/TinyChevronIcon.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
|
||||
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -219,6 +247,23 @@ const queueButtonTooltip = computed(() => {
|
||||
return t('menu.runWorkflow')
|
||||
})
|
||||
|
||||
const { isFeatureEnabled, isUserEnabled, maxConcurrentJobs, setUserEnabled } =
|
||||
useConcurrentExecution()
|
||||
|
||||
const isParallelToggleVisible = isFeatureEnabled
|
||||
const isParallelToggleDisabled = computed(
|
||||
() => selectedQueueMode.value !== 'disabled'
|
||||
)
|
||||
const parallelToggleChecked = isUserEnabled
|
||||
|
||||
function onParallelToggle(checked: boolean) {
|
||||
void setUserEnabled(checked)
|
||||
}
|
||||
|
||||
function toggleParallel() {
|
||||
onParallelToggle(!parallelToggleChecked.value)
|
||||
}
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const queuePrompt = async (e: Event) => {
|
||||
if (isStopInstantAction.value) {
|
||||
|
||||
47
src/components/dialog/ConcurrentExecutionDialog.vue
Normal file
47
src/components/dialog/ConcurrentExecutionDialog.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:header="$t('concurrentExecution.onboarding.title')"
|
||||
modal
|
||||
:closable="true"
|
||||
:draggable="false"
|
||||
class="max-w-md"
|
||||
>
|
||||
<p class="text-sm text-muted-color">
|
||||
{{ $t('concurrentExecution.onboarding.description') }}
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button autofocus @click="dismiss">
|
||||
{{ $t('concurrentExecution.onboarding.gotIt') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
|
||||
|
||||
const { isConcurrentExecutionEnabled, hasSeenOnboarding, markOnboardingSeen } =
|
||||
useConcurrentExecution()
|
||||
|
||||
const dismissed = ref(false)
|
||||
|
||||
const visible = computed({
|
||||
get: () =>
|
||||
isConcurrentExecutionEnabled.value &&
|
||||
!hasSeenOnboarding.value &&
|
||||
!dismissed.value,
|
||||
set: () => {
|
||||
dismissed.value = true
|
||||
}
|
||||
})
|
||||
|
||||
async function dismiss() {
|
||||
await markOnboardingSeen()
|
||||
dismissed.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,19 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/composables/useConcurrentExecution', () => ({
|
||||
useConcurrentExecution: () => ({
|
||||
isConcurrentExecutionEnabled: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const QueueJobItemStub = defineComponent({
|
||||
name: 'QueueJobItemStub',
|
||||
props: {
|
||||
@@ -56,6 +64,10 @@ const mountComponent = (groups: JobGroup[]) =>
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('JobGroupsList hover behavior', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
|
||||
@@ -24,10 +24,16 @@
|
||||
:progress-current-percent="ji.progressCurrentPercent"
|
||||
:running-node-name="ji.runningNodeName"
|
||||
:active-details-id="activeDetailsId"
|
||||
:is-focused="
|
||||
isConcurrentExecutionEnabled && ji.state === 'running'
|
||||
? ji.id === String(executionStore.focusedJobId ?? '')
|
||||
: undefined
|
||||
"
|
||||
@cancel="emitCancelItem(ji)"
|
||||
@delete="emitDeleteItem(ji)"
|
||||
@menu="(ev) => $emit('menu', ji, ev)"
|
||||
@view="$emit('viewItem', ji)"
|
||||
@focus="executionStore.setFocusedJob(ji.id)"
|
||||
@details-enter="onDetailsEnter"
|
||||
@details-leave="onDetailsLeave"
|
||||
/>
|
||||
@@ -37,11 +43,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
|
||||
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const { isConcurrentExecutionEnabled } = useConcurrentExecution()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
|
||||
@@ -170,6 +170,25 @@
|
||||
<slot name="secondary">{{ rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job focus button - always visible (concurrent execution) -->
|
||||
<Button
|
||||
v-if="state === 'running' && isFocused !== undefined"
|
||||
v-tooltip.top="focusTooltipConfig"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('queue.jobItem.focusTooltip')"
|
||||
@click.stop="emit('focus')"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
isFocused ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]',
|
||||
isFocused ? 'text-text-primary' : 'text-text-secondary'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<Button
|
||||
v-if="state === 'running' && computedShowClear"
|
||||
@@ -212,7 +231,8 @@ const {
|
||||
showMenu,
|
||||
progressTotalPercent,
|
||||
progressCurrentPercent,
|
||||
activeDetailsId = null
|
||||
activeDetailsId = null,
|
||||
isFocused
|
||||
} = defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
@@ -226,6 +246,7 @@ const {
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
activeDetailsId?: string | null
|
||||
isFocused?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -233,6 +254,7 @@ const emit = defineEmits<{
|
||||
(e: 'delete'): void
|
||||
(e: 'menu', event: MouseEvent): void
|
||||
(e: 'view'): void
|
||||
(e: 'focus'): void
|
||||
(e: 'details-enter', jobId: string): void
|
||||
(e: 'details-leave', jobId: string): void
|
||||
}>()
|
||||
@@ -250,6 +272,9 @@ const {
|
||||
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
|
||||
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
const focusTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('queue.jobItem.focusTooltip'))
|
||||
)
|
||||
|
||||
const rowRef = ref<HTMLDivElement | null>(null)
|
||||
const showDetails = computed(() => activeDetailsId === jobId)
|
||||
|
||||
@@ -110,6 +110,8 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
|
||||
let executionStoreMock: {
|
||||
activeJobId: string | null
|
||||
focusedJobId: string | null
|
||||
runningJobIds: string[]
|
||||
executingNode: null | { title?: string; type?: string }
|
||||
isJobInitializing: (jobId?: string | number) => boolean
|
||||
}
|
||||
@@ -121,6 +123,8 @@ const ensureExecutionStore = () => {
|
||||
if (!executionStoreMock) {
|
||||
executionStoreMock = reactive({
|
||||
activeJobId: null as string | null,
|
||||
focusedJobId: null as string | null,
|
||||
runningJobIds: [] as string[],
|
||||
executingNode: null as null | { title?: string; type?: string },
|
||||
isJobInitializing: (jobId?: string | number) =>
|
||||
isJobInitializingMock(jobId)
|
||||
@@ -484,6 +488,8 @@ describe('useJobList', () => {
|
||||
]
|
||||
|
||||
executionStoreMock.activeJobId = 'active'
|
||||
executionStoreMock.focusedJobId = 'active'
|
||||
executionStoreMock.runningJobIds = ['active']
|
||||
executionStoreMock.executingNode = { title: 'Render Node' }
|
||||
totalPercent.value = 80
|
||||
currentNodePercent.value = 40
|
||||
|
||||
@@ -261,8 +261,12 @@ export function useJobList() {
|
||||
|
||||
const jobItems = computed<JobListItem[]>(() => {
|
||||
return filteredTaskEntries.value.map(({ task, state }) => {
|
||||
const isActive =
|
||||
String(task.jobId ?? '') === String(executionStore.activeJobId ?? '')
|
||||
const isRunning = executionStore.runningJobIds.includes(
|
||||
String(task.jobId ?? '')
|
||||
)
|
||||
const isFocused =
|
||||
String(task.jobId ?? '') === String(executionStore.focusedJobId ?? '')
|
||||
const isActive = isRunning || isFocused
|
||||
const showAddedHint = shouldShowAddedHint(task, state)
|
||||
const promptKey = taskIdToKey(task.jobId)
|
||||
const promptPreviewUrl =
|
||||
|
||||
@@ -26,13 +26,23 @@ const executionStore = reactive<{
|
||||
}
|
||||
}
|
||||
} | null
|
||||
focusedJob: {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: { id: number; type: string }[]
|
||||
}
|
||||
}
|
||||
}
|
||||
} | null
|
||||
}>({
|
||||
isIdle: true,
|
||||
executionProgress: 0,
|
||||
executingNode: null,
|
||||
executingNodeProgress: 0,
|
||||
nodeProgressStates: {},
|
||||
activeJob: null
|
||||
activeJob: null,
|
||||
focusedJob: null
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
@@ -77,6 +87,7 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.executingNodeProgress = 0
|
||||
executionStore.nodeProgressStates = {}
|
||||
executionStore.activeJob = null
|
||||
executionStore.focusedJob = null
|
||||
|
||||
// reset setting and workflow stores
|
||||
vi.mocked(settingStore.get).mockReturnValue('Enabled')
|
||||
@@ -196,6 +207,15 @@ describe('useBrowserTabTitle', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
executionStore.focusedJob = {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: [{ id: 1, type: 'Foo' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const scope: EffectScope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
|
||||
@@ -77,7 +77,7 @@ export const useBrowserTabTitle = () => {
|
||||
const [nodeId, state] = runningNodes[0]
|
||||
const progress = Math.round((state.value / state.max) * 100)
|
||||
const nodeType =
|
||||
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
|
||||
executionStore.focusedJob?.workflow?.changeTracker?.activeState.nodes.find(
|
||||
(n) => String(n.id) === nodeId
|
||||
)?.type || 'Node'
|
||||
|
||||
|
||||
50
src/composables/useConcurrentExecution.ts
Normal file
50
src/composables/useConcurrentExecution.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
export function useConcurrentExecution() {
|
||||
const settingStore = useSettingStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isFeatureEnabled = computed(
|
||||
() => flags.concurrentExecutionEnabled === true
|
||||
)
|
||||
|
||||
const isUserEnabled = computed(
|
||||
() => settingStore.get('Comfy.Cloud.ConcurrentExecution') === true
|
||||
)
|
||||
|
||||
const isConcurrentExecutionEnabled = computed(
|
||||
() => isFeatureEnabled.value && isUserEnabled.value
|
||||
)
|
||||
|
||||
const maxConcurrentJobs = computed(() => flags.maxConcurrentJobs as number)
|
||||
|
||||
const hasSeenOnboarding = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.Cloud.ConcurrentExecution.OnboardingSeen') ===
|
||||
true
|
||||
)
|
||||
|
||||
async function setUserEnabled(enabled: boolean) {
|
||||
await settingStore.set('Comfy.Cloud.ConcurrentExecution', enabled)
|
||||
}
|
||||
|
||||
async function markOnboardingSeen() {
|
||||
await settingStore.set(
|
||||
'Comfy.Cloud.ConcurrentExecution.OnboardingSeen',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
isFeatureEnabled,
|
||||
isUserEnabled,
|
||||
isConcurrentExecutionEnabled,
|
||||
maxConcurrentJobs,
|
||||
hasSeenOnboarding,
|
||||
setUserEnabled,
|
||||
markOnboardingSeen
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Interrupt',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await api.interrupt(executionStore.activeJobId)
|
||||
await api.interrupt(executionStore.focusedJobId)
|
||||
toastStore.add({
|
||||
severity: 'info',
|
||||
summary: t('g.interrupted'),
|
||||
|
||||
@@ -26,7 +26,9 @@ export enum ServerFeatureFlag {
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
|
||||
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
|
||||
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled'
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
CONCURRENT_EXECUTION_ENABLED = 'concurrent_execution_enabled',
|
||||
MAX_CONCURRENT_JOBS = 'max_concurrent_jobs'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +158,31 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.comfyhub_profile_gate_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get concurrentExecutionEnabled() {
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.CONCURRENT_EXECUTION_ENABLED
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
return (
|
||||
remoteConfig.value.concurrent_execution_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.CONCURRENT_EXECUTION_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get maxConcurrentJobs() {
|
||||
const override = getDevOverride<number>(
|
||||
ServerFeatureFlag.MAX_CONCURRENT_JOBS
|
||||
)
|
||||
if (override !== undefined) return Math.max(1, override)
|
||||
|
||||
const configured =
|
||||
remoteConfig.value.max_concurrent_jobs ??
|
||||
api.getServerFeature(ServerFeatureFlag.MAX_CONCURRENT_JOBS, 1)
|
||||
return Math.max(1, configured)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
"save": "Save",
|
||||
"saveAnyway": "Save Anyway",
|
||||
"saving": "Saving",
|
||||
"new": "New",
|
||||
"no": "No",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
@@ -1004,7 +1005,18 @@
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"queue": "Queue Panel",
|
||||
"fullscreen": "Fullscreen"
|
||||
"fullscreen": "Fullscreen",
|
||||
"nRunning": "{count} running",
|
||||
"nQueued": "{count} queued",
|
||||
"parallelExecution": "Run jobs in parallel",
|
||||
"parallelUpTo": "Up to {count}"
|
||||
},
|
||||
"concurrentExecution": {
|
||||
"onboarding": {
|
||||
"title": "Run Jobs in Parallel",
|
||||
"description": "You can now run multiple workflows at the same time. Use the eye icon in the queue to control which job's progress is shown on the canvas.",
|
||||
"gotIt": "Got it"
|
||||
}
|
||||
},
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplicate Tab",
|
||||
@@ -1222,6 +1234,9 @@
|
||||
"jobAddedToQueue": "Job queued",
|
||||
"jobQueueing": "Job queuing",
|
||||
"completedIn": "Finished in {duration}",
|
||||
"jobItem": {
|
||||
"focusTooltip": "Progress currently shown on canvas"
|
||||
},
|
||||
"jobMenu": {
|
||||
"openAsWorkflowNewTab": "Open as workflow in new tab",
|
||||
"openWorkflowNewTab": "Open workflow in new tab",
|
||||
|
||||
@@ -55,4 +55,6 @@ export type RemoteConfig = {
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
max_concurrent_jobs?: number
|
||||
concurrent_execution_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -1268,5 +1268,21 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.42.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Cloud.ConcurrentExecution',
|
||||
name: 'Run jobs in parallel',
|
||||
tooltip:
|
||||
'When enabled, multiple workflow runs execute concurrently instead of queuing sequentially.',
|
||||
type: isCloud ? 'boolean' : 'hidden',
|
||||
defaultValue: true,
|
||||
versionAdded: '1.42.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Cloud.ConcurrentExecution.OnboardingSeen',
|
||||
name: 'Concurrent execution onboarding dialog seen',
|
||||
type: 'hidden',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.42.0'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -36,6 +36,9 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
get activeJobId() {
|
||||
return activeJobIdRef.value
|
||||
},
|
||||
get focusedJobId() {
|
||||
return activeJobIdRef.value
|
||||
},
|
||||
get jobIdToSessionWorkflowPath() {
|
||||
return jobIdToWorkflowPathRef.value
|
||||
}
|
||||
|
||||
@@ -258,12 +258,12 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
|
||||
function handleExecuted({ detail }: CustomEvent<ExecutedWsMessage>) {
|
||||
const jobId = detail.prompt_id
|
||||
if (jobId !== executionStore.activeJobId) return
|
||||
if (jobId !== executionStore.focusedJobId) return
|
||||
onNodeExecuted(jobId, detail)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => executionStore.activeJobId,
|
||||
() => executionStore.focusedJobId,
|
||||
(jobId, oldJobId) => {
|
||||
if (!isAppMode.value) return
|
||||
if (oldJobId && oldJobId !== jobId) {
|
||||
@@ -282,7 +282,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
() => jobPreviewStore.nodePreviewsByPromptId,
|
||||
(previews) => {
|
||||
if (!isAppMode.value) return
|
||||
const jobId = executionStore.activeJobId
|
||||
const jobId = executionStore.focusedJobId
|
||||
if (!jobId) return
|
||||
const preview = previews[jobId]
|
||||
if (preview) onLatentPreview(jobId, preview.url, preview.nodeId)
|
||||
|
||||
@@ -466,7 +466,9 @@ const zSettings = z.object({
|
||||
'Comfy.RightSidePanel.IsOpen': z.boolean(),
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean(),
|
||||
'LiteGraph.Group.SelectChildrenOnClick': z.boolean()
|
||||
'LiteGraph.Group.SelectChildrenOnClick': z.boolean(),
|
||||
'Comfy.Cloud.ConcurrentExecution': z.boolean(),
|
||||
'Comfy.Cloud.ConcurrentExecution.OnboardingSeen': z.boolean()
|
||||
})
|
||||
|
||||
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
|
||||
|
||||
@@ -764,6 +764,18 @@ export class ComfyApp {
|
||||
useNodeOutputStore()
|
||||
const blobUrl = createSharedObjectUrl(blob)
|
||||
useJobPreviewStore().setPreviewUrl(jobId, blobUrl, displayNodeId)
|
||||
|
||||
// Only render preview on canvas if this is the focused job
|
||||
const executionStore = useExecutionStore()
|
||||
if (
|
||||
jobId &&
|
||||
executionStore.focusedJobId &&
|
||||
jobId !== executionStore.focusedJobId
|
||||
) {
|
||||
releaseSharedObjectUrl(blobUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure clean up if `executing` event is missed.
|
||||
revokePreviewsByExecutionId(displayNodeId)
|
||||
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
@@ -9,10 +10,12 @@ import {
|
||||
export function setupAutoQueueHandler() {
|
||||
const queueCountStore = useQueuePendingTaskCountStore()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const { isConcurrentExecutionEnabled } = useConcurrentExecution()
|
||||
|
||||
let graphHasChanged = false
|
||||
let internalCount = 0 // Use an internal counter here so it is instantly updated when re-queuing
|
||||
api.addEventListener('graphChanged', () => {
|
||||
if (isConcurrentExecutionEnabled.value) return
|
||||
if (queueSettingsStore.mode === 'change') {
|
||||
if (internalCount) {
|
||||
graphHasChanged = true
|
||||
@@ -29,6 +32,7 @@ export function setupAutoQueueHandler() {
|
||||
async () => {
|
||||
internalCount = queueCountStore.count
|
||||
if (!internalCount && !app.lastExecutionError) {
|
||||
if (isConcurrentExecutionEnabled.value) return
|
||||
if (
|
||||
isInstantRunningMode(queueSettingsStore.mode) ||
|
||||
(queueSettingsStore.mode === 'change' && graphHasChanged)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
@@ -15,6 +16,20 @@ import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
const mockConcurrentExecutionEnabled = ref(false)
|
||||
|
||||
vi.mock('@/composables/useConcurrentExecution', () => ({
|
||||
useConcurrentExecution: () => ({
|
||||
isConcurrentExecutionEnabled: mockConcurrentExecutionEnabled,
|
||||
isFeatureEnabled: ref(false),
|
||||
isUserEnabled: ref(false),
|
||||
maxConcurrentJobs: ref(1),
|
||||
hasSeenOnboarding: ref(false),
|
||||
setUserEnabled: vi.fn(),
|
||||
markOnboardingSeen: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock the workflowStore
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
|
||||
@@ -56,7 +71,8 @@ vi.mock('@/scripts/api', () => ({
|
||||
apiEventHandlers.delete(event)
|
||||
}),
|
||||
clientId: 'test-client',
|
||||
apiURL: vi.fn((path: string) => `/api${path}`)
|
||||
apiURL: vi.fn((path: string) => `/api${path}`),
|
||||
getServerFeature: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -430,6 +446,300 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - focusedJobId management', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should initialize focusedJobId as null', () => {
|
||||
expect(store.focusedJobId).toBeNull()
|
||||
})
|
||||
|
||||
it('should set focusedJobId and update nodeProgressStates via setFocusedJob', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
},
|
||||
'job-2': {
|
||||
'node-2': {
|
||||
value: 30,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-2',
|
||||
prompt_id: 'job-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store.setFocusedJob('job-1')
|
||||
expect(store.focusedJobId).toBe('job-1')
|
||||
expect(store.nodeProgressStates).toEqual({
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
})
|
||||
|
||||
store.setFocusedJob('job-2')
|
||||
expect(store.focusedJobId).toBe('job-2')
|
||||
expect(store.nodeProgressStates).toEqual({
|
||||
'node-2': {
|
||||
value: 30,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-2',
|
||||
prompt_id: 'job-2'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear nodeProgressStates when setFocusedJob is called with null', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
store.setFocusedJob('job-1')
|
||||
expect(store.nodeProgressStates).not.toEqual({})
|
||||
|
||||
store.setFocusedJob(null)
|
||||
expect(store.focusedJobId).toBeNull()
|
||||
expect(store.nodeProgressStates).toEqual({})
|
||||
})
|
||||
|
||||
it('should return undefined for focusedJob when no job is focused', () => {
|
||||
expect(store.focusedJob).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return the focused QueuedJob from focusedJob computed', () => {
|
||||
store.queuedJobs = {
|
||||
'job-1': { nodes: { n1: false, n2: true } }
|
||||
}
|
||||
store.setFocusedJob('job-1')
|
||||
expect(store.focusedJob).toEqual({ nodes: { n1: false, n2: true } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - isConcurrentExecutionActive', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should be false when no jobs are running', () => {
|
||||
expect(store.isConcurrentExecutionActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should be false when only one job is running', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(store.isConcurrentExecutionActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should be true when multiple jobs are running', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
},
|
||||
'job-2': {
|
||||
'node-2': {
|
||||
value: 30,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-2',
|
||||
prompt_id: 'job-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(store.isConcurrentExecutionActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - isIdle with multi-job', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConcurrentExecutionEnabled.value = true
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should be true when no jobs are running', () => {
|
||||
expect(store.isIdle).toBe(true)
|
||||
})
|
||||
|
||||
it('should be false when at least one job is running', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 0,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(store.isIdle).toBe(false)
|
||||
})
|
||||
|
||||
it('should be true when all jobs are finished (not running)', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 100,
|
||||
max: 100,
|
||||
state: 'finished',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(store.isIdle).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - resetExecutionState auto-advance', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should set focusedJobId to null when last running job finishes', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
store.activeJobId = 'job-1'
|
||||
store.setFocusedJob('job-1')
|
||||
|
||||
// When the last job finishes, nodeProgressStatesByJob will be empty
|
||||
// and focusedJobId should become null
|
||||
store.nodeProgressStatesByJob = {}
|
||||
store.setFocusedJob(null)
|
||||
expect(store.focusedJobId).toBeNull()
|
||||
expect(store.nodeProgressStates).toEqual({})
|
||||
})
|
||||
|
||||
it('should not change focusedJobId when a non-focused job finishes', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
},
|
||||
'job-2': {
|
||||
'node-2': {
|
||||
value: 30,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-2',
|
||||
prompt_id: 'job-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
store.setFocusedJob('job-1')
|
||||
|
||||
// job-2 finishes — focusedJobId should stay on job-1
|
||||
const updated = { ...store.nodeProgressStatesByJob }
|
||||
delete updated['job-2']
|
||||
store.nodeProgressStatesByJob = updated
|
||||
|
||||
// Focus should still be on job-1
|
||||
expect(store.focusedJobId).toBe('job-1')
|
||||
expect(store.nodeProgressStates).toEqual({
|
||||
'node-1': {
|
||||
value: 50,
|
||||
max: 100,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - executionProgress from focusedJob', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConcurrentExecutionEnabled.value = true
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should compute executionProgress from focused job nodes', () => {
|
||||
store.queuedJobs = {
|
||||
'job-1': { nodes: { n1: true, n2: false, n3: false } },
|
||||
'job-2': { nodes: { n4: true, n5: true } }
|
||||
}
|
||||
store.setFocusedJob('job-1')
|
||||
// 1 out of 3 done
|
||||
expect(store.executionProgress).toBeCloseTo(1 / 3)
|
||||
|
||||
store.setFocusedJob('job-2')
|
||||
// 2 out of 2 done
|
||||
expect(store.executionProgress).toBe(1)
|
||||
})
|
||||
|
||||
it('should return 0 when no job is focused', () => {
|
||||
expect(store.executionProgress).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
let store: ReturnType<typeof useExecutionErrorStore>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -57,9 +58,11 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { isConcurrentExecutionEnabled } = useConcurrentExecution()
|
||||
|
||||
const clientId = ref<string | null>(null)
|
||||
const activeJobId = ref<string | null>(null)
|
||||
const focusedJobId = ref<string | null>(null)
|
||||
const queuedJobs = ref<Record<NodeId, QueuedJob>>({})
|
||||
// This is the progress of all nodes in the currently executing workflow
|
||||
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
|
||||
@@ -170,7 +173,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const executingNode = computed<ComfyNode | null>(() => {
|
||||
if (!executingNodeId.value) return null
|
||||
|
||||
const workflow: ComfyWorkflow | undefined = activeJob.value?.workflow
|
||||
const job = isConcurrentExecutionEnabled.value
|
||||
? focusedJob.value
|
||||
: activeJob.value
|
||||
const workflow: ComfyWorkflow | undefined = job?.workflow
|
||||
if (!workflow) return null
|
||||
|
||||
const canvasState: ComfyWorkflowJSON | null =
|
||||
@@ -195,20 +201,36 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => queuedJobs.value[activeJobId.value ?? '']
|
||||
)
|
||||
|
||||
const focusedJob = computed<QueuedJob | undefined>(
|
||||
() => queuedJobs.value[focusedJobId.value ?? '']
|
||||
)
|
||||
|
||||
const isConcurrentExecutionActive = computed(
|
||||
() => runningJobIds.value.length > 1
|
||||
)
|
||||
|
||||
const _currentJob = computed(() =>
|
||||
isConcurrentExecutionEnabled.value ? focusedJob.value : activeJob.value
|
||||
)
|
||||
|
||||
const totalNodesToExecute = computed<number>(() => {
|
||||
if (!activeJob.value) return 0
|
||||
return Object.values(activeJob.value.nodes).length
|
||||
if (!_currentJob.value) return 0
|
||||
return Object.values(_currentJob.value.nodes).length
|
||||
})
|
||||
|
||||
const isIdle = computed<boolean>(() => !activeJobId.value)
|
||||
const isIdle = computed<boolean>(() =>
|
||||
isConcurrentExecutionEnabled.value
|
||||
? runningJobIds.value.length === 0
|
||||
: !activeJobId.value
|
||||
)
|
||||
|
||||
const nodesExecuted = computed<number>(() => {
|
||||
if (!activeJob.value) return 0
|
||||
return Object.values(activeJob.value.nodes).filter(Boolean).length
|
||||
if (!_currentJob.value) return 0
|
||||
return Object.values(_currentJob.value.nodes).filter(Boolean).length
|
||||
})
|
||||
|
||||
const executionProgress = computed<number>(() => {
|
||||
if (!activeJob.value) return 0
|
||||
if (!_currentJob.value) return 0
|
||||
const total = totalNodesToExecute.value
|
||||
const done = nodesExecuted.value
|
||||
return total > 0 ? done / total : 0
|
||||
@@ -251,6 +273,15 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
clearInitializationByJobId(activeJobId.value)
|
||||
|
||||
if (isConcurrentExecutionEnabled.value) {
|
||||
// Auto-focus the first job, or if the current focused job is no longer running
|
||||
if (!focusedJobId.value || !queuedJobs.value[focusedJobId.value]) {
|
||||
focusedJobId.value = activeJobId.value
|
||||
}
|
||||
} else {
|
||||
focusedJobId.value = activeJobId.value
|
||||
}
|
||||
|
||||
// Ensure path mapping exists — execution_start can arrive via WebSocket
|
||||
// before the HTTP response from queuePrompt triggers storeJob.
|
||||
if (!jobIdToSessionWorkflowPath.value.has(activeJobId.value)) {
|
||||
@@ -260,9 +291,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
if (!activeJob.value) return
|
||||
const job = queuedJobs.value[e.detail.prompt_id]
|
||||
if (!job) return
|
||||
for (const n of e.detail.nodes) {
|
||||
activeJob.value.nodes[n] = true
|
||||
job.nodes[n] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,22 +302,21 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
e: CustomEvent<ExecutionInterruptedWsMessage>
|
||||
) {
|
||||
const jobId = e.detail.prompt_id
|
||||
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
|
||||
clearInitializationByJobId(jobId)
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
|
||||
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
|
||||
if (!activeJob.value) return
|
||||
activeJob.value.nodes[e.detail.node] = true
|
||||
const job = queuedJobs.value[e.detail.prompt_id]
|
||||
if (!job) return
|
||||
job.nodes[e.detail.node] = true
|
||||
}
|
||||
|
||||
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
|
||||
if (isCloud && activeJobId.value) {
|
||||
useTelemetry()?.trackExecutionSuccess({
|
||||
jobId: activeJobId.value
|
||||
})
|
||||
}
|
||||
const jobId = e.detail.prompt_id
|
||||
if (jobId) {
|
||||
useTelemetry()?.trackExecutionSuccess({ jobId })
|
||||
}
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
|
||||
@@ -354,22 +385,34 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
...nodeProgressStatesByJob.value,
|
||||
[jobId]: nodes
|
||||
}
|
||||
evictOldProgressJobs()
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
// If we have progress for the currently executing node, update it for backwards compatibility
|
||||
if (executingNodeId.value && nodes[executingNodeId.value]) {
|
||||
const nodeState = nodes[executingNodeId.value]
|
||||
_executingNodeProgress.value = {
|
||||
value: nodeState.value,
|
||||
max: nodeState.max,
|
||||
prompt_id: nodeState.prompt_id,
|
||||
node: nodeState.display_node_id || nodeState.node_id
|
||||
const shouldUpdate = isConcurrentExecutionEnabled.value
|
||||
? jobId === focusedJobId.value
|
||||
: true
|
||||
|
||||
if (shouldUpdate) {
|
||||
if (!isConcurrentExecutionEnabled.value) evictOldProgressJobs()
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
// If we have progress for the currently executing node, update it for backwards compatibility
|
||||
if (executingNodeId.value && nodes[executingNodeId.value]) {
|
||||
const nodeState = nodes[executingNodeId.value]
|
||||
_executingNodeProgress.value = {
|
||||
value: nodeState.value,
|
||||
max: nodeState.max,
|
||||
prompt_id: nodeState.prompt_id,
|
||||
node: nodeState.display_node_id || nodeState.node_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
|
||||
if (
|
||||
isConcurrentExecutionEnabled.value &&
|
||||
e.detail.prompt_id !== focusedJobId.value
|
||||
)
|
||||
return
|
||||
_executingNodeProgress.value = e.detail
|
||||
}
|
||||
|
||||
@@ -492,8 +535,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
* Reset execution-related state after a run completes or is stopped.
|
||||
*/
|
||||
function resetExecutionState(jobIdParam?: string | null) {
|
||||
executionIdToLocatorCache.clear()
|
||||
nodeProgressStates.value = {}
|
||||
const jobId = jobIdParam ?? activeJobId.value ?? null
|
||||
if (jobId) {
|
||||
const map = { ...nodeProgressStatesByJob.value }
|
||||
@@ -501,14 +542,46 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeProgressStatesByJob.value = map
|
||||
useJobPreviewStore().clearPreview(jobId)
|
||||
}
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
|
||||
if (isConcurrentExecutionEnabled.value) {
|
||||
if (jobId === activeJobId.value || !jobIdParam) {
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
}
|
||||
activeJobId.value = null
|
||||
}
|
||||
if (jobId === focusedJobId.value || !jobIdParam) {
|
||||
// Auto-advance to next running job, or null
|
||||
focusedJobId.value =
|
||||
runningJobIds.value.find((id) => id !== jobId) ?? null
|
||||
nodeProgressStates.value = focusedJobId.value
|
||||
? (nodeProgressStatesByJob.value[focusedJobId.value] ?? {})
|
||||
: {}
|
||||
}
|
||||
} else {
|
||||
executionIdToLocatorCache.clear()
|
||||
nodeProgressStates.value = {}
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
}
|
||||
activeJobId.value = null
|
||||
focusedJobId.value = null
|
||||
}
|
||||
activeJobId.value = null
|
||||
|
||||
_executingNodeProgress.value = null
|
||||
executionErrorStore.clearPromptError()
|
||||
}
|
||||
|
||||
function setFocusedJob(jobId: string | null) {
|
||||
focusedJobId.value = jobId
|
||||
_executingNodeProgress.value = null
|
||||
if (jobId) {
|
||||
nodeProgressStates.value = nodeProgressStatesByJob.value[jobId] ?? {}
|
||||
} else {
|
||||
nodeProgressStates.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeIdIfExecuting(nodeId: string | number) {
|
||||
const nodeIdStr = String(nodeId)
|
||||
return nodeIdStr.includes(':')
|
||||
@@ -625,6 +698,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
executingNodeId,
|
||||
executingNodeIds,
|
||||
activeJob,
|
||||
focusedJobId,
|
||||
focusedJob,
|
||||
isConcurrentExecutionActive,
|
||||
setFocusedJob,
|
||||
totalNodesToExecute,
|
||||
nodesExecuted,
|
||||
executionProgress,
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<AssetExportProgressDialog />
|
||||
<ManagerProgressToast />
|
||||
<UnloadWindowConfirmDialog v-if="!isDesktop" />
|
||||
<ConcurrentExecutionDialog />
|
||||
<MenuHamburger />
|
||||
</template>
|
||||
|
||||
@@ -48,6 +49,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { runWhenGlobalIdle } from '@/base/common/async'
|
||||
import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
import ConcurrentExecutionDialog from '@/components/dialog/ConcurrentExecutionDialog.vue'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
|
||||
Reference in New Issue
Block a user