mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 02:32:18 +00:00
Compare commits
48 Commits
claude/sla
...
pysssss/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6b7437db | ||
|
|
bad9642fad | ||
|
|
ab1b72a8df | ||
|
|
615f5724f4 | ||
|
|
67af4415a8 | ||
|
|
3d2699c07c | ||
|
|
a597050d7c | ||
|
|
c44155bcb3 | ||
|
|
99d6f87f00 | ||
|
|
c545c9d5d0 | ||
|
|
7d73f01588 | ||
|
|
663efeff35 | ||
|
|
f88a42619e | ||
|
|
ca86212d66 | ||
|
|
2e3a77567e | ||
|
|
d85db6367e | ||
|
|
c8f1e0395a | ||
|
|
7d00323935 | ||
|
|
cc85df9ae4 | ||
|
|
def0def247 | ||
|
|
4754be7567 | ||
|
|
b52785a8a0 | ||
|
|
1b16b0f6f8 | ||
|
|
fedce61238 | ||
|
|
726ae23cfd | ||
|
|
7a4f7dc4de | ||
|
|
824f8bb281 | ||
|
|
a4975a0e62 | ||
|
|
9a4aff542c | ||
|
|
34dd961561 | ||
|
|
d78e4b92cb | ||
|
|
41d4adaf45 | ||
|
|
99d31a7376 | ||
|
|
193d4827af | ||
|
|
594c2497fd | ||
|
|
1a94e34c0c | ||
|
|
1aa10e7c8f | ||
|
|
91ad183c24 | ||
|
|
e637471353 | ||
|
|
98532a8217 | ||
|
|
70b514a48d | ||
|
|
f80b87b46c | ||
|
|
bde6678cd7 | ||
|
|
37477b2e43 | ||
|
|
3f07dd255f | ||
|
|
2ed895c3dc | ||
|
|
f5b0b70676 | ||
|
|
9058d3ec54 |
35
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
35
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -34,13 +34,10 @@ jobs:
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
env:
|
||||
DISTRIBUTION: localhost
|
||||
|
||||
- name: Scan dist for GTM telemetry references
|
||||
- name: Scan dist for telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Google Tag Manager references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
@@ -49,33 +46,7 @@ jobs:
|
||||
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
|
||||
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Google Tag Manager references found in dist assets!'
|
||||
echo 'GTM must be properly tree-shaken from OSS builds.'
|
||||
echo 'Telemetry references found in dist assets.'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No GTM references found'
|
||||
|
||||
- name: Scan dist for Mixpanel telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Mixpanel references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '(?i)mixpanel\.init' \
|
||||
-e '(?i)mixpanel\.identify' \
|
||||
-e 'MixpanelTelemetryProvider' \
|
||||
-e 'mp\.comfy\.org' \
|
||||
-e 'mixpanel-browser' \
|
||||
-e '(?i)mixpanel\.track\(' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Mixpanel references found in dist assets!'
|
||||
echo 'Mixpanel must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Mixpanel references found'
|
||||
echo 'No telemetry references found in dist assets.'
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
|
||||
test('@mobile confirm dialog buttons are visible with long unbreakable text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const longFilename = 'workflow_checkpoint_' + 'a'.repeat(200) + '.json'
|
||||
|
||||
await comfyPage.page.evaluate((msg) => {
|
||||
window
|
||||
.app!.extensionManager.dialog.confirm({
|
||||
title: 'Confirm',
|
||||
type: 'default',
|
||||
message: msg
|
||||
})
|
||||
.catch(() => {})
|
||||
}, longFilename)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
|
||||
await expect(confirmButton).toBeVisible()
|
||||
await expect(confirmButton).toBeInViewport()
|
||||
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
||||
await expect(cancelButton).toBeVisible()
|
||||
await expect(cancelButton).toBeInViewport()
|
||||
})
|
||||
})
|
||||
@@ -61,21 +61,16 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
})
|
||||
|
||||
test.describe('Execution error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Should display an error message when an execution error occurs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.queueButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for the error overlay to be visible
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
// Wait for the element with the .comfy-execution-error selector to be visible
|
||||
const executionError = comfyPage.page.locator('.comfy-error-report')
|
||||
await expect(executionError).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,29 +7,22 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
})
|
||||
|
||||
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test(
|
||||
'Report error on unconnected slot',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.getByRole('button', { name: 'Dismiss' })
|
||||
.locator('.p-dialog')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.waitFor({ state: 'hidden' })
|
||||
await comfyPage.page.locator('.comfy-error-report').waitFor({
|
||||
state: 'hidden'
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'execution-error-unconnected-slot.png'
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 97 KiB |
@@ -13,9 +13,6 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
// Wait for the legacy menu to appear and canvas to settle after layout shift.
|
||||
await comfyPage.page.locator('.comfy-menu').waitFor({ state: 'visible' })
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
@@ -339,7 +339,7 @@ describe('TopMenuSection', () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activeJobId = 'job-1'
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
@@ -429,7 +429,7 @@ describe('TopMenuSection', () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activeJobId = 'job-1'
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorOverlay />
|
||||
<QueueProgressOverlay
|
||||
v-if="isQueueProgressOverlayEnabled"
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
@@ -157,7 +156,6 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
@@ -292,12 +290,12 @@ const showQueueContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
JobListItem,
|
||||
JobStatus
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
TaskItemImpl,
|
||||
useQueueSettingsStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/utils/graphHasMissingNodes', () => ({
|
||||
graphHasMissingNodes: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
shiftDown: false
|
||||
})
|
||||
}))
|
||||
|
||||
const SplitButtonStub = defineComponent({
|
||||
name: 'SplitButton',
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
severity: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<button
|
||||
data-testid="split-button"
|
||||
:data-label="label"
|
||||
:data-severity="severity"
|
||||
>
|
||||
<slot name="icon" />
|
||||
</button>
|
||||
`
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
menu: {
|
||||
run: 'Run',
|
||||
disabledTooltip: 'Disabled tooltip',
|
||||
onChange: 'On Change',
|
||||
onChangeTooltip: 'On change tooltip',
|
||||
instant: 'Instant',
|
||||
instantTooltip: 'Instant tooltip',
|
||||
stopRunInstant: 'Stop Run (Instant)',
|
||||
stopRunInstantTooltip: 'Stop running',
|
||||
runWorkflow: 'Run workflow',
|
||||
runWorkflowFront: 'Run workflow front',
|
||||
runWorkflowDisabled: 'Run workflow disabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
const job: JobListItem = {
|
||||
id,
|
||||
status,
|
||||
create_time: Date.now(),
|
||||
priority: 1
|
||||
}
|
||||
|
||||
return new TaskItemImpl(job)
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
|
||||
return mount(ComfyQueueButton, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
},
|
||||
stubs: {
|
||||
SplitButton: SplitButtonStub,
|
||||
BatchCountEdit: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyQueueButton', () => {
|
||||
it('keeps the run instant presentation while idle even with active jobs', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-idle'
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
||||
|
||||
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
|
||||
expect(splitButton.attributes('data-severity')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to stop presentation when instant mode is armed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-running'
|
||||
await nextTick()
|
||||
|
||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
||||
|
||||
expect(splitButton.attributes('data-label')).toBe('Stop Run (Instant)')
|
||||
expect(splitButton.attributes('data-severity')).toBe('danger')
|
||||
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disarms instant mode without interrupting even when jobs are active', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-running'
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
await wrapper.get('[data-testid="queue-button"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
const splitButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
|
||||
expect(splitButtonWhileStopping.attributes('data-label')).toBe(
|
||||
'Run (Instant)'
|
||||
)
|
||||
expect(splitButtonWhileStopping.attributes('data-severity')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
|
||||
expect(commandStore.execute).not.toHaveBeenCalled()
|
||||
|
||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
|
||||
expect(splitButton.attributes('data-severity')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('activates instant running mode when queueing again', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-idle'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.get('[data-testid="queue-button"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-running')
|
||||
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.QueuePrompt', {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,8 @@
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
:label="queueButtonLabel"
|
||||
:severity="queueButtonSeverity"
|
||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@@ -22,7 +22,7 @@
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:variant="item.key === selectedQueueMode ? 'primary' : 'secondary'"
|
||||
:variant="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
size="sm"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
@@ -48,11 +48,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
isInstantMode,
|
||||
isInstantRunningMode,
|
||||
useQueueSettingsStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
@@ -67,12 +63,6 @@ const hasMissingNodes = computed(() =>
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
|
||||
|
||||
const selectedQueueMode = computed<QueueModeMenuKey>(() =>
|
||||
isInstantMode(queueMode.value) ? 'instant-idle' : queueMode.value
|
||||
)
|
||||
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
const items: Record<string, MenuItem> = {
|
||||
disabled: {
|
||||
@@ -96,15 +86,15 @@ const queueModeMenuItemLookup = computed(() => {
|
||||
}
|
||||
}
|
||||
if (!isCloud) {
|
||||
items['instant-idle'] = {
|
||||
key: 'instant-idle',
|
||||
items.instant = {
|
||||
key: 'instant',
|
||||
label: `${t('menu.run')} (${t('menu.instant')})`,
|
||||
tooltip: t('menu.instantTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_instant_selected'
|
||||
})
|
||||
queueMode.value = 'instant-idle'
|
||||
queueMode.value = 'instant'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +104,7 @@ const queueModeMenuItemLookup = computed(() => {
|
||||
const activeQueueModeMenuItem = computed(() => {
|
||||
// Fallback to disabled mode if current mode is not available (e.g., instant mode in cloud)
|
||||
return (
|
||||
queueModeMenuItemLookup.value[selectedQueueMode.value] ||
|
||||
queueModeMenuItemLookup.value[queueMode.value] ||
|
||||
queueModeMenuItemLookup.value.disabled
|
||||
)
|
||||
})
|
||||
@@ -122,24 +112,7 @@ const queueModeMenuItems = computed(() =>
|
||||
Object.values(queueModeMenuItemLookup.value)
|
||||
)
|
||||
|
||||
const isStopInstantAction = computed(() =>
|
||||
isInstantRunningMode(queueMode.value)
|
||||
)
|
||||
|
||||
const queueButtonLabel = computed(() =>
|
||||
isStopInstantAction.value
|
||||
? t('menu.stopRunInstant')
|
||||
: String(activeQueueModeMenuItem.value?.label ?? '')
|
||||
)
|
||||
|
||||
const queueButtonSeverity = computed(() =>
|
||||
isStopInstantAction.value ? 'danger' : 'primary'
|
||||
)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (isStopInstantAction.value) {
|
||||
return 'icon-[lucide--square]'
|
||||
}
|
||||
if (hasMissingNodes.value) {
|
||||
return 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
@@ -149,7 +122,7 @@ const iconClass = computed(() => {
|
||||
if (queueMode.value === 'disabled') {
|
||||
return 'icon-[lucide--play]'
|
||||
}
|
||||
if (isInstantMode(queueMode.value)) {
|
||||
if (queueMode.value === 'instant') {
|
||||
return 'icon-[lucide--fast-forward]'
|
||||
}
|
||||
if (queueMode.value === 'change') {
|
||||
@@ -159,9 +132,6 @@ const iconClass = computed(() => {
|
||||
})
|
||||
|
||||
const queueButtonTooltip = computed(() => {
|
||||
if (isStopInstantAction.value) {
|
||||
return t('menu.stopRunInstantTooltip')
|
||||
}
|
||||
if (hasMissingNodes.value) {
|
||||
return t('menu.runWorkflowDisabled')
|
||||
}
|
||||
@@ -173,20 +143,11 @@ const queueButtonTooltip = computed(() => {
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const queuePrompt = async (e: Event) => {
|
||||
if (isStopInstantAction.value) {
|
||||
queueMode.value = 'instant-idle'
|
||||
return
|
||||
}
|
||||
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
if (isInstantMode(queueMode.value)) {
|
||||
queueMode.value = 'instant-running'
|
||||
}
|
||||
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
|
||||
@@ -73,10 +73,6 @@ function getDialogPt(item: {
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.global-dialog {
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-header {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
|
||||
@apply pb-0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col break-words px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
|
||||
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
|
||||
>
|
||||
<p v-if="promptTextReal">
|
||||
{{ promptTextReal }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="w-full flex flex-wrap gap-2 justify-end px-2 pb-2">
|
||||
<section class="w-full flex gap-2 justify-end px-2 pb-2">
|
||||
<Button :disabled variant="textonly" autofocus @click="$emit('cancel')">
|
||||
{{ cancelTextX }}
|
||||
</Button>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
|
||||
|
||||
type Props = ComponentProps<typeof ConfirmationDialogContent>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
describe('ConfirmationDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
function mountComponent(props: Partial<Props> = {}) {
|
||||
return mount(ConfirmationDialogContent, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n]
|
||||
},
|
||||
props: {
|
||||
message: 'Test message',
|
||||
type: 'default',
|
||||
onConfirm: vi.fn(),
|
||||
...props
|
||||
} as Props
|
||||
})
|
||||
}
|
||||
|
||||
it('renders long messages without breaking layout', () => {
|
||||
const longFilename =
|
||||
'workflow_checkpoint_' + 'a'.repeat(200) + '.safetensors'
|
||||
const wrapper = mountComponent({ message: longFilename })
|
||||
expect(wrapper.text()).toContain(longFilename)
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,21 @@
|
||||
<template>
|
||||
<section class="m-2 mt-4 flex flex-col gap-6 whitespace-pre-wrap break-words">
|
||||
<div>
|
||||
<span>{{ message }}</span>
|
||||
<ul v-if="itemList?.length" class="m-0 mt-2 flex flex-col gap-2 pl-4">
|
||||
<li v-for="item of itemList" :key="item">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<Message
|
||||
v-if="hint"
|
||||
class="mt-2"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-4">
|
||||
<section class="prompt-dialog-content m-2 mt-4 flex flex-col gap-6">
|
||||
<span>{{ message }}</span>
|
||||
<ul v-if="itemList?.length" class="m-0 flex flex-col gap-2 pl-4">
|
||||
<li v-for="item of itemList" :key="item">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<Message
|
||||
v-if="hint"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<div class="flex justify-end gap-4">
|
||||
<div
|
||||
v-if="type === 'overwriteBlueprint'"
|
||||
class="flex flex-col justify-start gap-1"
|
||||
@@ -154,3 +151,9 @@ const onConfirm = () => {
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.prompt-dialog-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="-translate-y-3 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
>
|
||||
<div v-if="isVisible" class="flex justify-end w-full pointer-events-none">
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
data-testid="error-overlay"
|
||||
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
{{ errorCountLabel }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-5 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3">
|
||||
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
<li
|
||||
v-for="(message, idx) in groupedErrorMessages"
|
||||
:key="idx"
|
||||
class="flex items-baseline gap-2 text-sm leading-snug text-muted-foreground min-w-0"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
<span class="break-words line-clamp-3 whitespace-pre-wrap">{{
|
||||
message
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-3">
|
||||
<Button variant="muted-textonly" size="unset" @click="dismiss">
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
|
||||
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
|
||||
)
|
||||
|
||||
function dismiss() {
|
||||
executionStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
@@ -28,7 +28,6 @@
|
||||
</span>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearAllJobsTooltip"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@@ -110,9 +109,6 @@ const { t } = useI18n()
|
||||
|
||||
const morePopoverRef = ref<PopoverMethods | null>(null)
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
const clearAllJobsTooltip = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip'))
|
||||
)
|
||||
|
||||
const onMoreClick = (event: MouseEvent) => {
|
||||
morePopoverRef.value?.toggle(event)
|
||||
|
||||
@@ -204,22 +204,22 @@ const {
|
||||
const displayedJobGroups = computed(() => groupedJobItems.value)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const jobId = item.taskRef?.jobId
|
||||
if (!jobId) return
|
||||
const promptId = item.taskRef?.promptId
|
||||
if (!promptId) return
|
||||
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
// Running/initializing jobs: interrupt execution
|
||||
// Cloud backend uses deleteItem, local uses interrupt
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', jobId)
|
||||
await api.deleteItem('queue', promptId)
|
||||
} else {
|
||||
await api.interrupt(jobId)
|
||||
await api.interrupt(promptId)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(jobId)
|
||||
executionStore.clearInitializationByPromptId(promptId)
|
||||
await queueStore.update()
|
||||
} else if (item.state === 'pending') {
|
||||
// Pending jobs: remove from queue
|
||||
await api.deleteItem('queue', jobId)
|
||||
await api.deleteItem('queue', promptId)
|
||||
await queueStore.update()
|
||||
}
|
||||
})
|
||||
@@ -249,11 +249,11 @@ const openAssetsSidebar = () => {
|
||||
|
||||
const focusAssetInSidebar = async (item: JobListItem) => {
|
||||
const task = item.taskRef
|
||||
const jobId = task?.jobId
|
||||
const promptId = task?.promptId
|
||||
const preview = task?.previewOutput
|
||||
if (!jobId || !preview) return
|
||||
if (!promptId || !preview) return
|
||||
|
||||
const assetId = String(jobId)
|
||||
const assetId = String(promptId)
|
||||
openAssetsSidebar()
|
||||
await nextTick()
|
||||
await assetsStore.updateHistory()
|
||||
@@ -275,37 +275,37 @@ const inspectJobAsset = wrapWithErrorHandlingAsync(
|
||||
)
|
||||
|
||||
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
|
||||
// Capture pending jobIds before clearing
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
// Capture pending promptIds before clearing
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
|
||||
// Clear initialization state for removed jobs
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
// Clear initialization state for removed prompts
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
})
|
||||
|
||||
const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
const tasks = queueStore.runningTasks
|
||||
const jobIds = tasks
|
||||
.map((task) => task.jobId)
|
||||
const promptIds = tasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
if (!jobIds.length) return
|
||||
if (!promptIds.length) return
|
||||
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByPromptIds(promptIds)
|
||||
await queueStore.update()
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(jobIds.map((id) => api.interrupt(id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await Promise.all(promptIds.map((id) => api.interrupt(id)))
|
||||
executionStore.clearInitializationByPromptIds(promptIds)
|
||||
await queueStore.update()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
const buttonStub = {
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="button-stub"
|
||||
:data-disabled="String(disabled)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const createEntries = (): MenuEntry[] => [
|
||||
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
},
|
||||
{ kind: 'divider', key: 'divider-1' }
|
||||
]
|
||||
|
||||
const mountComponent = (entries: MenuEntry[]) =>
|
||||
mount(JobContextMenu, {
|
||||
props: { entries },
|
||||
global: {
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div class="popover-stub"><slot /></div>'
|
||||
},
|
||||
Button: buttonStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('JobContextMenu', () => {
|
||||
it('passes disabled state to action buttons', () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
|
||||
const buttons = wrapper.findAll('.button-stub')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].attributes('data-disabled')).toBe('false')
|
||||
expect(buttons[1].attributes('data-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('emits action for enabled entries', async () => {
|
||||
const entries = createEntries()
|
||||
const wrapper = mountComponent(entries)
|
||||
|
||||
await wrapper.findAll('.button-stub')[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
|
||||
})
|
||||
|
||||
it('does not emit action for disabled entries', async () => {
|
||||
const wrapper = mountComponent([
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
}
|
||||
])
|
||||
|
||||
await wrapper.get('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,6 @@
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
:aria-label="entry.label"
|
||||
:disabled="entry.disabled"
|
||||
@click="onEntry(entry)"
|
||||
>
|
||||
<i
|
||||
@@ -69,7 +68,6 @@ function hide() {
|
||||
}
|
||||
|
||||
function onEntry(entry: MenuEntry) {
|
||||
if (entry.kind === 'divider' || entry.disabled) return
|
||||
emit('action', entry)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function resetStores() {
|
||||
queue.runningTasks = []
|
||||
queue.historyTasks = []
|
||||
|
||||
exec.nodeProgressStatesByJob = {}
|
||||
exec.nodeProgressStatesByPrompt = {}
|
||||
}
|
||||
|
||||
function makeTask(
|
||||
@@ -145,10 +145,10 @@ export const Queued: Story = {
|
||||
makePendingTask('job-older-2', 101, Date.now() - 30_000)
|
||||
)
|
||||
|
||||
// Queued at (in metadata on job tuple)
|
||||
// Queued at (in metadata on prompt[4])
|
||||
|
||||
// One running workflow
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
@@ -198,7 +198,7 @@ export const QueuedParallel: Story = {
|
||||
]
|
||||
|
||||
// Two parallel workflows running
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
@@ -248,7 +248,7 @@ export const Running: Story = {
|
||||
makeHistoryTask('hist-r3', 252, 60, true)
|
||||
]
|
||||
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 5,
|
||||
@@ -293,7 +293,7 @@ export const QueuedZeroAheadSingleRunning: Story = {
|
||||
|
||||
queue.runningTasks = [makeRunningTaskWithStart('running-1', 505, 20)]
|
||||
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
@@ -341,7 +341,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
|
||||
makeRunningTaskWithStart('running-b', 507, 10)
|
||||
]
|
||||
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 2,
|
||||
|
||||
@@ -139,7 +139,7 @@ const copyJobId = () => void copyToClipboard(jobIdValue.value)
|
||||
const taskForJob = computed(() => {
|
||||
const pid = props.jobId
|
||||
const findIn = (arr: TaskItemImpl[]) =>
|
||||
arr.find((t) => String(t.jobId ?? '') === String(pid))
|
||||
arr.find((t) => String(t.promptId ?? '') === String(pid))
|
||||
return (
|
||||
findIn(queueStore.pendingTasks) ||
|
||||
findIn(queueStore.runningTasks) ||
|
||||
@@ -151,7 +151,9 @@ const taskForJob = computed(() => {
|
||||
const jobState = computed(() => {
|
||||
const task = taskForJob.value
|
||||
if (!task) return null
|
||||
const isInitializing = executionStore.isJobInitializing(String(task?.jobId))
|
||||
const isInitializing = executionStore.isPromptInitializing(
|
||||
String(task?.promptId)
|
||||
)
|
||||
return jobStateFromTask(task, isInitializing)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReportin
|
||||
import type { ExecutionError } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const createTaskWithError = (
|
||||
jobId: string,
|
||||
promptId: string,
|
||||
errorMessage?: string,
|
||||
executionError?: ExecutionError,
|
||||
createTime?: number
|
||||
): TaskItemImpl =>
|
||||
({
|
||||
jobId,
|
||||
promptId,
|
||||
errorMessage,
|
||||
executionError,
|
||||
createTime: createTime ?? Date.now()
|
||||
|
||||
@@ -19,8 +19,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
|
||||
import TabError from './TabError.vue'
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
import TabNodes from './parameters/TabNodes.vue'
|
||||
@@ -41,7 +41,7 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -96,31 +96,30 @@ type RightSidePanelTabList = Array<{
|
||||
icon?: string
|
||||
}>
|
||||
|
||||
const hasDirectNodeError = computed(() =>
|
||||
selectedNodes.value.some((node) =>
|
||||
executionStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||
)
|
||||
//FIXME all errors if nothing selected?
|
||||
const selectedNodeErrors = computed(() =>
|
||||
selectedNodes.value
|
||||
.map((node) => executionStore.getNodeErrors(`${node.id}`))
|
||||
.filter((nodeError) => !!nodeError)
|
||||
)
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (allErrorExecutionIds.value.length === 0) return false
|
||||
return selectedNodes.value.some((node) => {
|
||||
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
||||
return executionStore.hasInternalErrorForNode(node.id)
|
||||
})
|
||||
})
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return hasDirectNodeError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = []
|
||||
if (
|
||||
selectedNodeErrors.value.length &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('g.error'),
|
||||
value: 'error',
|
||||
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
|
||||
hasRelevantErrors.value
|
||||
hasAnyError.value &&
|
||||
!hasSelection.value &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.errors'),
|
||||
@@ -316,9 +315,9 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<template v-else-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<template v-if="!hasSelection">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
</template>
|
||||
@@ -327,6 +326,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:node="selectedSingleNode"
|
||||
/>
|
||||
<template v-else>
|
||||
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
|
||||
30
src/components/rightSidePanel/TabError.vue
Normal file
30
src/components/rightSidePanel/TabError.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
errors: NodeError[]
|
||||
}>()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
|
||||
{{ t('g.copy') }}
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
|
||||
:key="index"
|
||||
class="px-2"
|
||||
>
|
||||
<h3 class="text-error" v-text="error.message" />
|
||||
<div class="text-muted-foreground" v-text="error.details" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,10 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<!-- Card Header (Node ID & Actions) -->
|
||||
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
@@ -22,7 +19,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
@@ -30,8 +27,7 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
@click.stop="emit('locateNode', card.nodeId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
@@ -47,8 +43,8 @@
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="error.message && !compact"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5 max-h-[4lh] overflow-y-auto"
|
||||
v-if="error.message"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
|
||||
>
|
||||
{{ error.message }}
|
||||
</p>
|
||||
@@ -73,7 +69,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full justify-center gap-2 h-8 text-xs"
|
||||
class="w-full justify-center gap-2 h-8 text-[11px]"
|
||||
@click="handleCopyError(error)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
@@ -92,15 +88,9 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
|
||||
const {
|
||||
card,
|
||||
showNodeIdBadge = false,
|
||||
compact = false
|
||||
} = defineProps<{
|
||||
const { card, showNodeIdBadge = false } = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
/** Hide card header and error message (used in single-node selection mode) */
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -111,18 +101,6 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
emit('locateNode', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph() {
|
||||
if (card.nodeId) {
|
||||
emit('enterSubgraph', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(error: ErrorItem) {
|
||||
emit(
|
||||
'copyToClipboard',
|
||||
|
||||
@@ -17,7 +17,6 @@ vi.mock('@/scripts/app', () => ({
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn()
|
||||
}))
|
||||
|
||||
|
||||
@@ -55,9 +55,8 @@
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@locate-node="focusNode"
|
||||
@enter-subgraph="enterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,16 +97,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
@@ -120,10 +119,10 @@ const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
@@ -131,30 +130,17 @@ const showNodeIdBadge = computed(
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
const { filteredGroups } = useErrorGroups(searchQuery, t)
|
||||
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
/**
|
||||
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
||||
* sets focusedErrorNodeId, expand only the group containing the target
|
||||
* node and collapse all others so the user sees the relevant errors
|
||||
* immediately.
|
||||
*/
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
for (const group of filteredGroups.value) {
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
(card) => card.graphNodeId === graphNodeId
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
@@ -163,14 +149,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
@@ -184,6 +162,6 @@ async function contactSupport() {
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
useCommandStore().execute('Comfy.ContactSupport')
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getRootParentNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'prompt_no_outputs',
|
||||
'no_prompt',
|
||||
'server_error'
|
||||
])
|
||||
|
||||
interface GroupEntry {
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
@@ -43,26 +25,20 @@ interface ErrorSearchItem {
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve display info for a node by its execution ID.
|
||||
* For group node internals, resolves the parent group node's title instead.
|
||||
*/
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
|
||||
|
||||
function resolveNodeInfo(nodeId: string): {
|
||||
title: string
|
||||
graphNodeId: string | undefined
|
||||
} {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
const parentNode = getRootParentNode(app.rootGraph, nodeId)
|
||||
const isParentGroupNode = parentNode ? isGroupNode(parentNode) : false
|
||||
|
||||
return {
|
||||
title: isParentGroupNode
|
||||
? parentNode?.title || ''
|
||||
: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined,
|
||||
isParentGroupNode
|
||||
title: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,56 +55,93 @@ function getOrCreateGroup(
|
||||
return entry.cards
|
||||
}
|
||||
|
||||
function createErrorCard(
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
return {
|
||||
id: `${idPrefix}-${nodeId}`,
|
||||
title: classType,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId) && !nodeInfo.isParentGroupNode,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In single-node mode, regroup cards by error message instead of class_type.
|
||||
* This lets the user see "what kinds of errors this node has" at a glance.
|
||||
*/
|
||||
function regroupByErrorMessage(
|
||||
groupsMap: Map<string, GroupEntry>
|
||||
): Map<string, GroupEntry> {
|
||||
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
|
||||
Array.from(g.cards.values())
|
||||
)
|
||||
|
||||
const cardErrorPairs = allCards.flatMap((card) =>
|
||||
card.errors.map((error) => ({ card, error }))
|
||||
)
|
||||
|
||||
const messageMap = new Map<string, GroupEntry>()
|
||||
for (const { card, error } of cardErrorPairs) {
|
||||
addCardErrorToGroup(messageMap, card, error)
|
||||
}
|
||||
|
||||
return messageMap
|
||||
}
|
||||
|
||||
function addCardErrorToGroup(
|
||||
messageMap: Map<string, GroupEntry>,
|
||||
card: ErrorCardData,
|
||||
error: ErrorItem
|
||||
function processPromptError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const group = getOrCreateGroup(messageMap, error.message, 1)
|
||||
if (!group.has(card.id)) {
|
||||
group.set(card.id, { ...card, errors: [] })
|
||||
if (!executionStore.lastPromptError) return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
cards.set('__prompt__', {
|
||||
id: '__prompt__',
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown
|
||||
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
|
||||
: error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `node-${nodeId}`,
|
||||
title: nodeError.class_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) continue
|
||||
card.errors.push(
|
||||
...nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
}))
|
||||
)
|
||||
}
|
||||
group.get(card.id)?.errors.push(error)
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
const nodeId = String(e.node_id)
|
||||
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
|
||||
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `exec-${nodeId}`,
|
||||
title: e.node_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) return
|
||||
card.errors.push({
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
})
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
@@ -144,14 +157,27 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
})
|
||||
}
|
||||
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
function buildErrorGroups(
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
): ErrorGroup[] {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap, executionStore, t)
|
||||
processNodeErrors(groupsMap, executionStore)
|
||||
processExecutionError(groupsMap, executionStore)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
}
|
||||
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
|
||||
if (!query) return groups
|
||||
|
||||
const searchableList: ErrorSearchItem[] = []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const group = groups[gi]
|
||||
const group = groups[gi]!
|
||||
for (let ci = 0; ci < group.cards.length; ci++) {
|
||||
const card = group.cards[ci]
|
||||
const card = group.cards[ci]!
|
||||
searchableList.push({
|
||||
groupIndex: gi,
|
||||
cardIndex: ci,
|
||||
@@ -193,194 +219,18 @@ export function useErrorGroups(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
const selectedNodeInfo = computed(() => {
|
||||
const items = canvasStore.selectedItems
|
||||
const nodeIds = new Set<string>()
|
||||
const containerIds = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
if (!isLGraphNode(item)) continue
|
||||
nodeIds.add(String(item.id))
|
||||
if (item instanceof SubgraphNode || isGroupNode(item)) {
|
||||
containerIds.add(String(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeIds: nodeIds.size > 0 ? nodeIds : null,
|
||||
containerIds
|
||||
}
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(
|
||||
() =>
|
||||
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
||||
selectedNodeInfo.value.containerIds.size === 0
|
||||
const errorGroups = computed<ErrorGroup[]>(() =>
|
||||
buildErrorGroups(executionStore, t)
|
||||
)
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
for (const execId of executionStore.allErrorExecutionIds) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (node) map.set(execId, node)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
const graphNode = errorNodeCache.value.get(executionNodeId)
|
||||
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
||||
|
||||
for (const containerId of selectedNodeInfo.value.containerIds) {
|
||||
if (executionNodeId.startsWith(`${containerId}:`)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
errors: ErrorItem[],
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (filterBySelection && !isErrorInSelection(nodeId)) return
|
||||
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
|
||||
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
|
||||
}
|
||||
cards.get(nodeId)?.errors.push(...errors)
|
||||
}
|
||||
|
||||
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
||||
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
|
||||
return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
// For server_error, resolve the i18n key based on the environment
|
||||
let errorTypeKey = error.type
|
||||
if (error.type === 'server_error') {
|
||||
errorTypeKey = isCloud ? 'server_error_cloud' : 'server_error_local'
|
||||
}
|
||||
const i18nKey = `rightSidePanel.promptErrors.${errorTypeKey}.desc`
|
||||
|
||||
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
|
||||
cards.set(PROMPT_CARD_ID, {
|
||||
id: PROMPT_CARD_ID,
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown ? t(i18nKey) : error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
})),
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
[
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
return isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
const groupedErrorMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
return searchErrorGroups(errorGroups.value, query)
|
||||
})
|
||||
|
||||
return {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
groupedErrorMessages
|
||||
errorGroups,
|
||||
filteredGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,17 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -108,24 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const hasDirectError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
})
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
const isContainer =
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
|
||||
return executionStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
if (canvasStore.selectedItems.length === 1) return false
|
||||
return hasDirectError.value || hasContainerInternalError.value
|
||||
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
@@ -220,10 +207,7 @@ defineExpose({
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
nodeHasError &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
"
|
||||
v-if="nodeHasError"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 rounded-lg text-sm"
|
||||
@@ -233,9 +217,9 @@ defineExpose({
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isEmpty"
|
||||
variant="muted-textonly"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 size-8 hover:text-base-foreground"
|
||||
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.resetAllParameters')"
|
||||
:aria-label="t('rightSidePanel.resetAllParameters')"
|
||||
@click.stop="handleResetAllWidgets"
|
||||
@@ -244,9 +228,9 @@ defineExpose({
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canShowLocateButton"
|
||||
variant="muted-textonly"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 mr-3 size-8 hover:text-base-foreground"
|
||||
class="subbutton shrink-0 mr-3 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.locateNode')"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import {
|
||||
demoteWidget,
|
||||
@@ -123,6 +123,12 @@ function handleResetToDefault() {
|
||||
if (!hasDefault.value) return
|
||||
emit('resetToDefault', defaultValue.value)
|
||||
}
|
||||
|
||||
const buttonClasses = cn([
|
||||
'border-none bg-transparent',
|
||||
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
||||
'cursor-pointer transition-all hover:bg-secondary-background-hover active:scale-95'
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,10 +137,8 @@ function handleResetToDefault() {
|
||||
class="text-muted-foreground bg-transparent hover:text-base-foreground hover:bg-secondary-background-hover active:scale-95 transition-all"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleRename()
|
||||
@@ -144,13 +148,11 @@ function handleResetToDefault() {
|
||||
>
|
||||
<i class="icon-[lucide--edit] size-4" />
|
||||
<span>{{ t('g.rename') }}</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
<button
|
||||
v-if="hasParents"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
if (isShownOnParents) handleHideInput()
|
||||
@@ -167,12 +169,10 @@ function handleResetToDefault() {
|
||||
<i class="icon-[lucide--eye] size-4" />
|
||||
<span>{{ t('rightSidePanel.showInput') }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleToggleFavorite()
|
||||
@@ -181,20 +181,18 @@ function handleResetToDefault() {
|
||||
"
|
||||
>
|
||||
<template v-if="isFavorited">
|
||||
<i class="icon-[lucide--star] size-4" />
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.removeFavorite') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--star] size-4" />
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
<button
|
||||
v-if="hasDefault"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
|
||||
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
|
||||
:disabled="isCurrentValueDefault"
|
||||
@click="
|
||||
() => {
|
||||
@@ -205,7 +203,7 @@ function handleResetToDefault() {
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
|
||||
</Button>
|
||||
</button>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</template>
|
||||
|
||||
@@ -80,7 +80,7 @@ const sampleAssets: AssetItem[] = [
|
||||
size: 1887437,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
jobId: 'job-running-1',
|
||||
promptId: 'job-running-1',
|
||||
nodeId: 12,
|
||||
executionTimeInSeconds: 1.84
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template #alt-title>
|
||||
<div
|
||||
@@ -10,7 +9,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ $t('assetBrowser.jobId') }}:</span>
|
||||
<span class="text-sm">{{ folderJobId?.substring(0, 8) }}</span>
|
||||
<span class="text-sm">{{ folderPromptId?.substring(0, 8) }}</span>
|
||||
<button
|
||||
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
|
||||
role="button"
|
||||
@@ -273,13 +272,11 @@ const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const executionStore = useExecutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderJobId = ref<string | null>(null)
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const expectedFolderCount = ref(0)
|
||||
const isInFolderView = computed(() => folderJobId.value !== null)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
const viewMode = useStorage<'list' | 'grid'>(
|
||||
'Comfy.Assets.Sidebar.ViewMode',
|
||||
'grid'
|
||||
@@ -533,7 +530,6 @@ watch(
|
||||
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
const assetList = assets ?? visibleAssets.value
|
||||
const index = assetList.findIndex((a) => a.id === asset.id)
|
||||
emit('assetSelected', asset)
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
@@ -563,13 +559,13 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
|
||||
@@ -632,14 +628,14 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
return
|
||||
}
|
||||
|
||||
const { jobId, executionTimeInSeconds } = metadata
|
||||
const { promptId, executionTimeInSeconds } = metadata
|
||||
|
||||
if (!jobId) {
|
||||
if (!promptId) {
|
||||
console.warn('Missing required folder view data')
|
||||
return
|
||||
}
|
||||
|
||||
folderJobId.value = jobId
|
||||
folderPromptId.value = promptId
|
||||
folderExecutionTime.value = executionTimeInSeconds
|
||||
expectedFolderCount.value = metadata.outputCount ?? 0
|
||||
|
||||
@@ -657,7 +653,7 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
const exitFolderView = () => {
|
||||
folderJobId.value = null
|
||||
folderPromptId.value = null
|
||||
folderExecutionTime.value = undefined
|
||||
expectedFolderCount.value = 0
|
||||
folderAssets.value = []
|
||||
@@ -683,9 +679,9 @@ const handleEmptySpaceClick = () => {
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
if (folderJobId.value) {
|
||||
if (folderPromptId.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(folderJobId.value)
|
||||
await navigator.clipboard.writeText(folderPromptId.value)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('mediaAsset.jobIdToast.copied'),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
@@ -16,7 +15,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
entries?: MenuItem[]
|
||||
entries?: { label: string; action?: () => void; icon?: string }[][]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
}>()
|
||||
@@ -41,34 +40,33 @@ defineProps<{
|
||||
>
|
||||
<slot>
|
||||
<div class="flex flex-col p-1">
|
||||
<template v-for="item in entries ?? []" :key="item.label">
|
||||
<section
|
||||
v-for="(entryGroup, index) in entries ?? []"
|
||||
:key="index"
|
||||
class="flex flex-col border-b-2 last:border-none border-border-subtle"
|
||||
>
|
||||
<div
|
||||
v-if="item.separator"
|
||||
class="border-b w-full border-border-subtle"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
v-for="{ label, action, icon } in entryGroup"
|
||||
:key="label"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-row gap-4 p-2 rounded-sm my-1',
|
||||
item.disabled
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: item.command &&
|
||||
'cursor-pointer hover:bg-secondary-background-hover'
|
||||
action &&
|
||||
'cursor-pointer hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="
|
||||
(e) => {
|
||||
if (!item.command || item.disabled) return
|
||||
item.command({ originalEvent: e, item })
|
||||
() => {
|
||||
if (!action) return
|
||||
action()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<i v-if="icon" :class="icon" />
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</slot>
|
||||
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||
|
||||
@@ -2,16 +2,8 @@ import { nextTick } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getRootParentNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
@@ -37,40 +29,20 @@ async function navigateToGraph(targetGraph: LGraph) {
|
||||
export function useFocusNode() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
/* Locate and focus a node on the canvas by its execution ID. */
|
||||
async function focusNode(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
async function focusNode(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
// For group node internals, locate the root parent group node instead
|
||||
const parentNode = getRootParentNode(app.rootGraph, nodeId)
|
||||
|
||||
if (parentNode && isGroupNode(parentNode) && parentNode.graph) {
|
||||
await navigateToGraph(parentNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(parentNode.boundingRect)
|
||||
return
|
||||
}
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
async function enterSubgraph(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
|
||||
@@ -6,11 +6,10 @@ import type { Ref } from 'vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type TestTask = {
|
||||
jobId: string
|
||||
promptId: string
|
||||
queueIndex: number
|
||||
mockState: JobState
|
||||
executionTime?: number
|
||||
@@ -70,7 +69,7 @@ vi.mock('@/composables/queue/useQueueProgress', () => ({
|
||||
vi.mock('@/utils/queueDisplay', () => ({
|
||||
buildJobDisplay: vi.fn(
|
||||
(task: TaskItemImpl, state: JobState, options: BuildJobDisplayCtx) => ({
|
||||
primary: `Job ${task.jobId}`,
|
||||
primary: `Job ${task.promptId}`,
|
||||
secondary: `${state} meta`,
|
||||
iconName: `${state}-icon`,
|
||||
iconImageUrl: undefined,
|
||||
@@ -109,21 +108,21 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
}))
|
||||
|
||||
let executionStoreMock: {
|
||||
activeJobId: string | null
|
||||
activePromptId: string | null
|
||||
executingNode: null | { title?: string; type?: string }
|
||||
isJobInitializing: (jobId?: string | number) => boolean
|
||||
isPromptInitializing: (promptId?: string | number) => boolean
|
||||
}
|
||||
let isJobInitializingMock: (jobId?: string | number) => boolean
|
||||
let isPromptInitializingMock: (promptId?: string | number) => boolean
|
||||
const ensureExecutionStore = () => {
|
||||
if (!isJobInitializingMock) {
|
||||
isJobInitializingMock = vi.fn(() => false)
|
||||
if (!isPromptInitializingMock) {
|
||||
isPromptInitializingMock = vi.fn(() => false)
|
||||
}
|
||||
if (!executionStoreMock) {
|
||||
executionStoreMock = reactive({
|
||||
activeJobId: null as string | null,
|
||||
activePromptId: null as string | null,
|
||||
executingNode: null as null | { title?: string; type?: string },
|
||||
isJobInitializing: (jobId?: string | number) =>
|
||||
isJobInitializingMock(jobId)
|
||||
isPromptInitializing: (promptId?: string | number) =>
|
||||
isPromptInitializingMock(promptId)
|
||||
})
|
||||
}
|
||||
return executionStoreMock
|
||||
@@ -173,7 +172,8 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
const createTask = (
|
||||
overrides: Partial<TestTask> & { mockState?: JobState } = {}
|
||||
): TestTask => ({
|
||||
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
|
||||
promptId:
|
||||
overrides.promptId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
|
||||
queueIndex: overrides.queueIndex ?? 0,
|
||||
mockState: overrides.mockState ?? 'pending',
|
||||
executionTime: overrides.executionTime,
|
||||
@@ -201,7 +201,7 @@ const resetStores = () => {
|
||||
queueStore.historyTasks = []
|
||||
|
||||
const executionStore = ensureExecutionStore()
|
||||
executionStore.activeJobId = null
|
||||
executionStore.activePromptId = null
|
||||
executionStore.executingNode = null
|
||||
|
||||
const jobPreviewStore = ensureJobPreviewStore()
|
||||
@@ -219,9 +219,9 @@ const resetStores = () => {
|
||||
localeRef.value = 'en-US'
|
||||
tMock.mockClear()
|
||||
|
||||
if (isJobInitializingMock) {
|
||||
vi.mocked(isJobInitializingMock).mockReset()
|
||||
vi.mocked(isJobInitializingMock).mockReturnValue(false)
|
||||
if (isPromptInitializingMock) {
|
||||
vi.mocked(isPromptInitializingMock).mockReset()
|
||||
vi.mocked(isPromptInitializingMock).mockReturnValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,82 +255,10 @@ describe('useJobList', () => {
|
||||
return api!
|
||||
}
|
||||
|
||||
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
await flush()
|
||||
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: true })
|
||||
)
|
||||
|
||||
vi.mocked(buildJobDisplay).mockClear()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flush()
|
||||
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('removes pending hint immediately when the task leaves the queue', async () => {
|
||||
vi.useFakeTimers()
|
||||
const taskId = '2'
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
await flush()
|
||||
jobItems.value
|
||||
|
||||
queueStoreMock.pendingTasks = []
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
vi.mocked(buildJobDisplay).mockClear()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
|
||||
]
|
||||
await flush()
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up timeouts on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
initComposable()
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts all tasks by create time', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
jobId: 'p',
|
||||
promptId: 'p',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
createTime: 3000
|
||||
@@ -338,7 +266,7 @@ describe('useJobList', () => {
|
||||
]
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'r',
|
||||
promptId: 'r',
|
||||
queueIndex: 5,
|
||||
mockState: 'running',
|
||||
createTime: 2000
|
||||
@@ -346,7 +274,7 @@ describe('useJobList', () => {
|
||||
]
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'h',
|
||||
promptId: 'h',
|
||||
queueIndex: 3,
|
||||
mockState: 'completed',
|
||||
createTime: 1000,
|
||||
@@ -357,7 +285,7 @@ describe('useJobList', () => {
|
||||
const { allTasksSorted } = initComposable()
|
||||
await flush()
|
||||
|
||||
expect(allTasksSorted.value.map((task) => task.jobId)).toEqual([
|
||||
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
|
||||
'p',
|
||||
'r',
|
||||
'h'
|
||||
@@ -366,9 +294,9 @@ describe('useJobList', () => {
|
||||
|
||||
it('filters by job tab and resets failed tab when failures disappear', async () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
|
||||
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
|
||||
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' }),
|
||||
createTask({ promptId: 'f', queueIndex: 2, mockState: 'failed' }),
|
||||
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
@@ -376,15 +304,15 @@ describe('useJobList', () => {
|
||||
|
||||
instance.selectedJobTab.value = 'Completed'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['c'])
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['c'])
|
||||
|
||||
instance.selectedJobTab.value = 'Failed'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['f'])
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['f'])
|
||||
expect(instance.hasFailedJobs.value).toBe(true)
|
||||
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
|
||||
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' })
|
||||
]
|
||||
await flush()
|
||||
|
||||
@@ -395,13 +323,13 @@ describe('useJobList', () => {
|
||||
it('filters by active workflow when requested', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
jobId: 'wf-1',
|
||||
promptId: 'wf-1',
|
||||
queueIndex: 2,
|
||||
mockState: 'pending',
|
||||
workflowId: 'workflow-1'
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'wf-2',
|
||||
promptId: 'wf-2',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
workflowId: 'workflow-2'
|
||||
@@ -418,26 +346,28 @@ describe('useJobList', () => {
|
||||
workflowStoreMock.activeWorkflow = { activeState: { id: 'workflow-1' } }
|
||||
await flush()
|
||||
|
||||
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['wf-1'])
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual([
|
||||
'wf-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates job items with active progress and compute hours', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'active',
|
||||
promptId: 'active',
|
||||
queueIndex: 3,
|
||||
mockState: 'running',
|
||||
executionTime: 7_200_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'other',
|
||||
promptId: 'other',
|
||||
queueIndex: 2,
|
||||
mockState: 'running',
|
||||
executionTime: 3_600_000
|
||||
})
|
||||
]
|
||||
|
||||
executionStoreMock.activeJobId = 'active'
|
||||
executionStoreMock.activePromptId = 'active'
|
||||
executionStoreMock.executingNode = { title: 'Render Node' }
|
||||
totalPercent.value = 80
|
||||
currentNodePercent.value = 40
|
||||
@@ -460,7 +390,7 @@ describe('useJobList', () => {
|
||||
it('assigns preview urls for running jobs when previews enabled', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'live-preview',
|
||||
promptId: 'live-preview',
|
||||
queueIndex: 1,
|
||||
mockState: 'running'
|
||||
})
|
||||
@@ -479,7 +409,7 @@ describe('useJobList', () => {
|
||||
it('omits preview urls when previews are disabled', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'disabled-preview',
|
||||
promptId: 'disabled-preview',
|
||||
queueIndex: 1,
|
||||
mockState: 'running'
|
||||
})
|
||||
@@ -520,28 +450,28 @@ describe('useJobList', () => {
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'today-small',
|
||||
promptId: 'today-small',
|
||||
queueIndex: 4,
|
||||
mockState: 'completed',
|
||||
executionEndTimestamp: Date.now(),
|
||||
executionTime: 2_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'today-large',
|
||||
promptId: 'today-large',
|
||||
queueIndex: 3,
|
||||
mockState: 'completed',
|
||||
executionEndTimestamp: Date.now(),
|
||||
executionTime: 5_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'yesterday',
|
||||
promptId: 'yesterday',
|
||||
queueIndex: 2,
|
||||
mockState: 'failed',
|
||||
executionEndTimestamp: Date.now() - 86_400_000,
|
||||
executionTime: 1_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'undated',
|
||||
promptId: 'undated',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending'
|
||||
})
|
||||
|
||||
@@ -127,7 +127,7 @@ export function useJobList() {
|
||||
watch(
|
||||
() =>
|
||||
queueStore.pendingTasks
|
||||
.map((task) => taskIdToKey(task.jobId))
|
||||
.map((task) => taskIdToKey(task.promptId))
|
||||
.filter((id): id is string => !!id),
|
||||
(pendingIds) => {
|
||||
const pendingSet = new Set(pendingIds)
|
||||
@@ -158,7 +158,7 @@ export function useJobList() {
|
||||
|
||||
const shouldShowAddedHint = (task: TaskItemImpl, state: JobState) => {
|
||||
if (state !== 'pending') return false
|
||||
const id = taskIdToKey(task.jobId)
|
||||
const id = taskIdToKey(task.promptId)
|
||||
if (!id) return false
|
||||
return recentlyAddedPendingIds.value.has(id)
|
||||
}
|
||||
@@ -183,8 +183,8 @@ export function useJobList() {
|
||||
})
|
||||
const undatedLabel = computed(() => t('queue.jobList.undated') || 'Undated')
|
||||
|
||||
const isJobInitializing = (jobId: string | number | undefined) =>
|
||||
executionStore.isJobInitializing(jobId)
|
||||
const isJobInitializing = (promptId: string | number | undefined) =>
|
||||
executionStore.isPromptInitializing(promptId)
|
||||
|
||||
const currentNodeName = computed(() => {
|
||||
return resolveNodeDisplayName(executionStore.executingNode, {
|
||||
@@ -212,7 +212,7 @@ export function useJobList() {
|
||||
const tasksWithJobState = computed<TaskWithState[]>(() =>
|
||||
allTasksSorted.value.map((task) => ({
|
||||
task,
|
||||
state: jobStateFromTask(task, isJobInitializing(task?.jobId))
|
||||
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -255,9 +255,10 @@ export function useJobList() {
|
||||
const jobItems = computed<JobListItem[]>(() => {
|
||||
return filteredTaskEntries.value.map(({ task, state }) => {
|
||||
const isActive =
|
||||
String(task.jobId ?? '') === String(executionStore.activeJobId ?? '')
|
||||
String(task.promptId ?? '') ===
|
||||
String(executionStore.activePromptId ?? '')
|
||||
const showAddedHint = shouldShowAddedHint(task, state)
|
||||
const promptKey = taskIdToKey(task.jobId)
|
||||
const promptKey = taskIdToKey(task.promptId)
|
||||
const promptPreviewUrl =
|
||||
state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey
|
||||
? jobPreviewStore.previewsByPromptId[promptKey]
|
||||
@@ -276,7 +277,7 @@ export function useJobList() {
|
||||
})
|
||||
|
||||
return {
|
||||
id: String(task.jobId),
|
||||
id: String(task.promptId),
|
||||
title: display.primary,
|
||||
meta: display.secondary,
|
||||
state,
|
||||
@@ -333,7 +334,7 @@ export function useJobList() {
|
||||
groupIdx = groups.length - 1
|
||||
index.set(key, groupIdx)
|
||||
}
|
||||
const ji = jobItemById.value.get(String(task.jobId))
|
||||
const ji = jobItemById.value.get(String(task.promptId))
|
||||
if (ji) groups[groupIdx].items.push(ji)
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,8 @@ const interruptMock = vi.fn()
|
||||
const deleteItemMock = vi.fn()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
interrupt: (runningJobId: string | null) => interruptMock(runningJobId),
|
||||
interrupt: (runningPromptId: string | null) =>
|
||||
interruptMock(runningPromptId),
|
||||
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
|
||||
}
|
||||
}))
|
||||
@@ -119,7 +120,7 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
}))
|
||||
|
||||
const executionStoreMock = {
|
||||
clearInitializationByJobId: vi.fn()
|
||||
clearInitializationByPromptId: vi.fn()
|
||||
}
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStoreMock
|
||||
@@ -742,17 +743,6 @@ describe('useJobMenu', () => {
|
||||
'd3',
|
||||
'delete'
|
||||
])
|
||||
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(false)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
|
||||
).toBe(false)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
await inspectEntry?.onClick?.()
|
||||
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
|
||||
@@ -770,7 +760,6 @@ describe('useJobMenu', () => {
|
||||
await nextTick()
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
expect(inspectEntry?.onClick).toBeUndefined()
|
||||
expect(inspectEntry?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('omits delete asset entry when no preview exists', async () => {
|
||||
@@ -778,15 +767,6 @@ describe('useJobMenu', () => {
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
|
||||
|
||||
await nextTick()
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(true)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
|
||||
).toBe(true)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
true
|
||||
)
|
||||
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
@@ -29,7 +29,6 @@ export type MenuEntry =
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void | Promise<void>
|
||||
}
|
||||
| { kind: 'divider'; key: string }
|
||||
@@ -84,7 +83,7 @@ export function useJobMenu(
|
||||
} else if (target.state === 'pending') {
|
||||
await api.deleteItem('queue', target.id)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(target.id)
|
||||
executionStore.clearInitializationByPromptId(target.id)
|
||||
await queueStore.update()
|
||||
}
|
||||
|
||||
@@ -240,14 +239,13 @@ export function useJobMenu(
|
||||
const item = currentMenuItem()
|
||||
const state = item?.state
|
||||
if (!state) return []
|
||||
const hasPreviewAsset = !!item?.taskRef?.previewOutput
|
||||
const hasDeletableAsset = !!item?.taskRef?.previewOutput
|
||||
if (state === 'completed') {
|
||||
return [
|
||||
{
|
||||
key: 'inspect-asset',
|
||||
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
disabled: !hasPreviewAsset || !onInspectAsset,
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const item = currentMenuItem()
|
||||
@@ -262,14 +260,12 @@ export function useJobMenu(
|
||||
'Add to current workflow'
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: addOutputLoaderNode
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: downloadPreviewAsset
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
@@ -293,7 +289,7 @@ export function useJobMenu(
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasPreviewAsset
|
||||
...(hasDeletableAsset
|
||||
? [
|
||||
{
|
||||
key: 'delete',
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
|
||||
|
||||
const { mockGetSetting, mockActiveJobsCount } = vi.hoisted(() => ({
|
||||
const { mockGetSetting, mockPendingTasks } = vi.hoisted(() => ({
|
||||
mockGetSetting: vi.fn(),
|
||||
mockActiveJobsCount: { value: 0 }
|
||||
mockPendingTasks: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -19,14 +19,14 @@ vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
activeJobsCount: mockActiveJobsCount.value
|
||||
pendingTasks: mockPendingTasks
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetsSidebarTab', () => {
|
||||
it('hides icon badge when QPO V2 is disabled', () => {
|
||||
mockGetSetting.mockReturnValue(false)
|
||||
mockActiveJobsCount.value = 3
|
||||
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {})
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
@@ -34,22 +34,13 @@ describe('useAssetsSidebarTab', () => {
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows active job count when QPO V2 is enabled', () => {
|
||||
it('shows pending task count when QPO V2 is enabled', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockActiveJobsCount.value = 3
|
||||
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {}, {})
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
expect(typeof sidebarTab.iconBadge).toBe('function')
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
|
||||
})
|
||||
|
||||
it('hides badge when no active jobs', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockActiveJobsCount.value = 0
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,8 +22,9 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
}
|
||||
|
||||
const queueStore = useQueueStore()
|
||||
const count = queueStore.activeJobsCount
|
||||
return count > 0 ? count.toString() : null
|
||||
return queueStore.pendingTasks.length > 0
|
||||
? queueStore.pendingTasks.length.toString()
|
||||
: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const executionStore = reactive<{
|
||||
executingNode: unknown
|
||||
executingNodeProgress: number
|
||||
nodeProgressStates: Record<string, unknown>
|
||||
activeJob: {
|
||||
activePrompt: {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
@@ -32,7 +32,7 @@ const executionStore = reactive<{
|
||||
executingNode: null,
|
||||
executingNodeProgress: 0,
|
||||
nodeProgressStates: {},
|
||||
activeJob: null
|
||||
activePrompt: null
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
@@ -76,7 +76,7 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.executingNode = null
|
||||
executionStore.executingNodeProgress = 0
|
||||
executionStore.nodeProgressStates = {}
|
||||
executionStore.activeJob = null
|
||||
executionStore.activePrompt = null
|
||||
|
||||
// reset setting and workflow stores
|
||||
vi.mocked(settingStore.get).mockReturnValue('Enabled')
|
||||
@@ -187,7 +187,7 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.nodeProgressStates = {
|
||||
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
|
||||
}
|
||||
executionStore.activeJob = {
|
||||
executionStore.activePrompt = {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
|
||||
@@ -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.activePrompt?.workflow?.changeTracker?.activeState.nodes.find(
|
||||
(n) => String(n.id) === nodeId
|
||||
)?.type || 'Node'
|
||||
|
||||
|
||||
@@ -23,13 +23,7 @@ vi.mock('vue-i18n', async () => {
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockGraphClear = vi.fn()
|
||||
const mockCanvas = {
|
||||
subgraph: undefined,
|
||||
selectedItems: new Set(),
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
selectItems: vi.fn()
|
||||
}
|
||||
const mockCanvas = { subgraph: undefined }
|
||||
|
||||
return {
|
||||
app: {
|
||||
@@ -111,8 +105,7 @@ vi.mock('@/stores/subgraphStore', () => ({
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
getCanvas: () => app.canvas,
|
||||
canvas: app.canvas
|
||||
getCanvas: () => app.canvas
|
||||
})),
|
||||
useTitleEditorStore: vi.fn(() => ({
|
||||
titleEditorTarget: null
|
||||
@@ -307,48 +300,6 @@ describe('useCoreCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas clipboard commands', () => {
|
||||
function findCommand(id: string) {
|
||||
return useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
app.canvas.selectedItems = new Set()
|
||||
vi.mocked(app.canvas.copyToClipboard).mockClear()
|
||||
vi.mocked(app.canvas.pasteFromClipboard).mockClear()
|
||||
vi.mocked(app.canvas.selectItems).mockClear()
|
||||
})
|
||||
|
||||
it('should copy selected items when selection exists', async () => {
|
||||
app.canvas.selectedItems = new Set([
|
||||
{}
|
||||
]) as typeof app.canvas.selectedItems
|
||||
|
||||
await findCommand('Comfy.Canvas.CopySelected').function()
|
||||
|
||||
expect(app.canvas.copyToClipboard).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should not copy when no items are selected', async () => {
|
||||
await findCommand('Comfy.Canvas.CopySelected').function()
|
||||
|
||||
expect(app.canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should paste from clipboard', async () => {
|
||||
await findCommand('Comfy.Canvas.PasteFromClipboard').function()
|
||||
|
||||
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should select all items', async () => {
|
||||
await findCommand('Comfy.Canvas.SelectAll').function()
|
||||
|
||||
// No arguments means "select all items on canvas"
|
||||
expect(app.canvas.selectItems).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph metadata commands', () => {
|
||||
beforeEach(() => {
|
||||
mockSubgraph.extra = {}
|
||||
|
||||
@@ -312,7 +312,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Interrupt',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await api.interrupt(executionStore.activeJobId)
|
||||
await api.interrupt(executionStore.activePromptId)
|
||||
toastStore.add({
|
||||
severity: 'info',
|
||||
summary: t('g.interrupted'),
|
||||
@@ -884,32 +884,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
window.open(staticUrls.forum, '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.CopySelected',
|
||||
icon: 'icon-[lucide--copy]',
|
||||
label: 'Copy',
|
||||
function: () => {
|
||||
if (app.canvas.selectedItems?.size) {
|
||||
app.canvas.copyToClipboard()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.PasteFromClipboard',
|
||||
icon: 'icon-[lucide--clipboard-paste]',
|
||||
label: 'Paste',
|
||||
function: () => {
|
||||
app.canvas.pasteFromClipboard()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.SelectAll',
|
||||
icon: 'icon-[lucide--lasso-select]',
|
||||
label: 'Select All',
|
||||
function: () => {
|
||||
app.canvas.selectItems()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.DeleteSelectedItems',
|
||||
icon: 'pi pi-trash',
|
||||
|
||||
@@ -14,14 +14,6 @@ export const CORE_MENU_COMMANDS = [
|
||||
]
|
||||
],
|
||||
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
|
||||
[
|
||||
['Edit'],
|
||||
[
|
||||
'Comfy.Canvas.CopySelected',
|
||||
'Comfy.Canvas.PasteFromClipboard',
|
||||
'Comfy.Canvas.SelectAll'
|
||||
]
|
||||
],
|
||||
[['Edit'], ['Comfy.ClearWorkflow']],
|
||||
[['Edit'], ['Comfy.OpenClipspace']],
|
||||
[
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
|
||||
@@ -191,7 +190,7 @@ function onCustomFloatCreated(this: LGraphNode) {
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.CustomWidgets',
|
||||
beforeRegisterNodeDef(nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name === 'CustomCombo')
|
||||
nodeType.prototype.onNodeCreated = useChainCallback(
|
||||
nodeType.prototype.onNodeCreated,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { processDynamicPrompt } from '@/utils/formatUtil'
|
||||
|
||||
@@ -7,7 +6,7 @@ import { processDynamicPrompt } from '@/utils/formatUtil'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.DynamicPrompts',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
nodeCreated(node) {
|
||||
if (node.widgets) {
|
||||
// Locate dynamic prompt text widgets
|
||||
// Include any widgets with dynamicPrompts set to true, and customtext
|
||||
|
||||
@@ -1993,11 +1993,11 @@ const ext: ComfyExtension = {
|
||||
await GroupNodeConfig.registerFromWorkflow(nodes, missingNodeTypes)
|
||||
}
|
||||
},
|
||||
addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
|
||||
addCustomNodeDefs(defs) {
|
||||
// Store this so we can mutate it later with group nodes
|
||||
globalDefs = defs
|
||||
},
|
||||
nodeCreated(node: LGraphNode) {
|
||||
nodeCreated(node) {
|
||||
if (GroupNodeHandler.isGroupNode(node)) {
|
||||
;(node as LGraphNode & { [GROUP]: GroupNodeHandler })[GROUP] =
|
||||
new GroupNodeHandler(node)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -12,7 +11,7 @@ type ImageCompareOutput = NodeOutputWith<{
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.ImageCompare',
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'ImageCompare') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.ImageCrop',
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'ImageCropV2') return
|
||||
|
||||
node.hideOutputImages = true
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
type Load3dPreviewOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, string?]
|
||||
@@ -328,7 +327,7 @@ useExtensionService().registerExtension({
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'Load3D') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
@@ -424,10 +423,7 @@ useExtensionService().registerExtension({
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3D',
|
||||
|
||||
async beforeRegisterNodeDef(
|
||||
_nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
async beforeRegisterNodeDef(_nodeType, nodeData) {
|
||||
if ('Preview3D' === nodeData.name) {
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
nodeData.input.required.image = ['PREVIEW_3D']
|
||||
@@ -466,7 +462,7 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'Preview3D') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
* - A user adds a 3D node from the node menu
|
||||
*/
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
@@ -56,10 +54,7 @@ function isLoad3dNodeType(nodeTypeName: string): boolean {
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Load3DLazy',
|
||||
|
||||
async beforeRegisterNodeDef(
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (isLoad3dNodeType(nodeData.name)) {
|
||||
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
|
||||
// Load3D's model_file as a mesh upload widget without hardcoding.
|
||||
|
||||
@@ -3,8 +3,6 @@ Preview Any - original implement from
|
||||
https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
|
||||
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
|
||||
*/
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type DOMWidget } from '@/scripts/domWidget'
|
||||
import { ComfyWidgets } from '@/scripts/widgets'
|
||||
@@ -12,10 +10,7 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.PreviewAny',
|
||||
async beforeRegisterNodeDef(
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === 'PreviewAny') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
|
||||
@@ -58,6 +53,7 @@ useExtensionService().registerExtension({
|
||||
showValueWidget.options.hidden = true
|
||||
showValueWidget.options.read_only = true
|
||||
showValueWidget.element.readOnly = true
|
||||
showValueWidget.element.disabled = true
|
||||
showValueWidget.serialize = false
|
||||
|
||||
showValueWidgetPlain.label = 'Preview'
|
||||
@@ -65,6 +61,7 @@ useExtensionService().registerExtension({
|
||||
showValueWidgetPlain.options.hidden = false
|
||||
showValueWidgetPlain.options.read_only = true
|
||||
showValueWidgetPlain.element.readOnly = true
|
||||
showValueWidgetPlain.element.disabled = true
|
||||
showValueWidgetPlain.serialize = false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { applyTextReplacements } from '@/utils/searchAndReplace'
|
||||
|
||||
import { app } from '../../scripts/app'
|
||||
@@ -23,10 +21,7 @@ const saveNodeTypes = new Set([
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.SaveImageExtraOutput',
|
||||
async beforeRegisterNodeDef(
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (saveNodeTypes.has(nodeData.name)) {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
// When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R
|
||||
|
||||
@@ -8,7 +8,6 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
type SaveMeshOutput = NodeOutputWith<{
|
||||
'3d'?: ResultItem[]
|
||||
@@ -27,10 +26,7 @@ const inputSpec: CustomInputSpec = {
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.SaveGLB',
|
||||
|
||||
async beforeRegisterNodeDef(
|
||||
_nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
async beforeRegisterNodeDef(_nodeType, nodeData) {
|
||||
if ('SaveGLB' === nodeData.name) {
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
nodeData.input.required.image = ['PREVIEW_3D']
|
||||
@@ -69,7 +65,7 @@ useExtensionService().registerExtension({
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'SaveGLB') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
@@ -88,10 +88,7 @@ async function uploadFile(
|
||||
// present.
|
||||
app.registerExtension({
|
||||
name: 'Comfy.AudioWidget',
|
||||
async beforeRegisterNodeDef(
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (
|
||||
[
|
||||
'LoadAudio',
|
||||
@@ -182,10 +179,7 @@ app.registerExtension({
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.UploadAudio',
|
||||
async beforeRegisterNodeDef(
|
||||
_nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
async beforeRegisterNodeDef(_nodeType, nodeData: ComfyNodeDef) {
|
||||
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
|
||||
nodeData.input.required.upload = ['AUDIOUPLOAD', {}]
|
||||
}
|
||||
@@ -425,7 +419,7 @@ app.registerExtension({
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'RecordAudio') return
|
||||
|
||||
await useAudioService().registerWavEncoder()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
type ComfyNodeDef,
|
||||
type InputSpec,
|
||||
@@ -36,7 +35,7 @@ const createUploadInput = (
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.UploadImage',
|
||||
beforeRegisterNodeDef(_nodeType: typeof LGraphNode, nodeData: ComfyNodeDef) {
|
||||
beforeRegisterNodeDef(_nodeType, nodeData: ComfyNodeDef) {
|
||||
const { input } = nodeData ?? {}
|
||||
const { required } = input ?? {}
|
||||
if (!required) return
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import { api } from '../../scripts/api'
|
||||
@@ -67,7 +66,7 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeCreated(node: LGraphNode) {
|
||||
nodeCreated(node) {
|
||||
if ((node.type, node.constructor.comfyClass !== 'WebcamCapture')) return
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
|
||||
@@ -14,7 +14,9 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
ComfyWidgets,
|
||||
@@ -231,15 +233,21 @@ export class PrimitiveNode extends LGraphNode {
|
||||
const [oldWidth, oldHeight] = this.size
|
||||
let widget: IBaseWidget
|
||||
|
||||
if (
|
||||
type === 'COMBO' &&
|
||||
assetService.shouldUseAssetBrowser(node.comfyClass, widgetName)
|
||||
) {
|
||||
widget = this._createAssetWidget(node, widgetName, inputData)
|
||||
const theirWidget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (theirWidget) widget.value = theirWidget.value
|
||||
this._finalizeWidget(widget, oldWidth, oldHeight, recreating)
|
||||
return
|
||||
// Cloud: Use asset widget for model-eligible inputs when asset API is enabled
|
||||
if (isCloud && type === 'COMBO') {
|
||||
const settingStore = useSettingStore()
|
||||
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
const isEligible = assetService.isAssetBrowserEligible(
|
||||
node.comfyClass,
|
||||
widgetName
|
||||
)
|
||||
if (isUsingAssetAPI && isEligible) {
|
||||
widget = this._createAssetWidget(node, widgetName, inputData)
|
||||
const theirWidget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (theirWidget) widget.value = theirWidget.value
|
||||
this._finalizeWidget(widget, oldWidth, oldHeight, recreating)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidWidgetType(type)) {
|
||||
@@ -555,10 +563,7 @@ export function mergeIfValid(
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.WidgetInputs',
|
||||
async beforeRegisterNodeDef(
|
||||
nodeType: typeof LGraphNode,
|
||||
_nodeData: ComfyNodeDef
|
||||
) {
|
||||
async beforeRegisterNodeDef(nodeType, _nodeData) {
|
||||
// @ts-expect-error adding extra property
|
||||
nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) {
|
||||
console.warn(
|
||||
|
||||
@@ -850,7 +850,6 @@
|
||||
"jobsAddedToQueue": "{count} job added to queue | {count} jobs added to queue",
|
||||
"cancelJobTooltip": "Cancel job",
|
||||
"clearQueueTooltip": "Clear queue",
|
||||
"clearAllJobsTooltip": "Cancel all running jobs",
|
||||
"clearHistoryDialogTitle": "Clear your job queue history?",
|
||||
"clearHistoryDialogDescription": "All the finished or failed jobs below will be removed from this Job queue panel.",
|
||||
"clearHistoryDialogAssetsNote": "Assets generated by these jobs won’t be deleted and can always be viewed from the assets panel."
|
||||
@@ -919,8 +918,6 @@
|
||||
"runWorkflowFront": "Run workflow (Queue at front)",
|
||||
"runWorkflowDisabled": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow.",
|
||||
"run": "Run",
|
||||
"stopRunInstant": "Stop Run (Instant)",
|
||||
"stopRunInstantTooltip": "Stop running",
|
||||
"execute": "Execute",
|
||||
"interrupt": "Cancel current run",
|
||||
"refresh": "Refresh node definitions",
|
||||
@@ -1381,7 +1378,6 @@
|
||||
"Execution": "Execution",
|
||||
"PLY": "PLY",
|
||||
"Workspace": "Workspace",
|
||||
"Error System": "Error System",
|
||||
"Other": "Other",
|
||||
"Secrets": "Secrets",
|
||||
"Error System": "Error System"
|
||||
@@ -3011,7 +3007,6 @@
|
||||
"hideAdvancedInputsButton": "Hide advanced inputs",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"seeError": "See Error",
|
||||
"promptErrors": {
|
||||
@@ -3020,12 +3015,6 @@
|
||||
},
|
||||
"no_prompt": {
|
||||
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "The server encountered an unexpected error. Please check the server logs."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "The server encountered an unexpected error. Please try again later."
|
||||
}
|
||||
},
|
||||
"errorHelp": "For more help, {github} or {support}",
|
||||
@@ -3034,10 +3023,6 @@
|
||||
"resetToDefault": "Reset to default",
|
||||
"resetAllParameters": "Reset all parameters"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
|
||||
"seeErrors": "See Errors"
|
||||
},
|
||||
"help": {
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
|
||||
@@ -27,7 +27,7 @@ export function mapTaskOutputToAssetItem(
|
||||
output: ResultItemImpl
|
||||
): AssetItem {
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: taskItem.jobId,
|
||||
promptId: taskItem.promptId,
|
||||
nodeId: output.nodeId,
|
||||
subfolder: output.subfolder,
|
||||
executionTimeInSeconds: taskItem.executionTimeInSeconds,
|
||||
@@ -36,7 +36,7 @@ export function mapTaskOutputToAssetItem(
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskItem.jobId,
|
||||
id: taskItem.promptId,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: taskItem.executionStartTimestamp
|
||||
|
||||
@@ -46,12 +46,12 @@ export function useMediaAssetActions() {
|
||||
assetType: string
|
||||
): Promise<void> => {
|
||||
if (assetType === 'output') {
|
||||
const jobId =
|
||||
getOutputAssetMetadata(asset.user_metadata)?.jobId || asset.id
|
||||
if (!jobId) {
|
||||
throw new Error('Unable to extract job ID from asset')
|
||||
const promptId =
|
||||
getOutputAssetMetadata(asset.user_metadata)?.promptId || asset.id
|
||||
if (!promptId) {
|
||||
throw new Error('Unable to extract prompt ID from asset')
|
||||
}
|
||||
await api.deleteItem('history', jobId)
|
||||
await api.deleteItem('history', promptId)
|
||||
} else {
|
||||
// Input assets can only be deleted in cloud environment
|
||||
if (!isCloud) {
|
||||
@@ -141,16 +141,16 @@ export function useMediaAssetActions() {
|
||||
for (const asset of assets) {
|
||||
if (getAssetType(asset) === 'output') {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const jobId = metadata?.jobId || asset.id
|
||||
if (!jobIds.includes(jobId)) {
|
||||
jobIds.push(jobId)
|
||||
const promptId = metadata?.promptId || asset.id
|
||||
if (!jobIds.includes(promptId)) {
|
||||
jobIds.push(promptId)
|
||||
}
|
||||
if (metadata?.jobId && asset.name) {
|
||||
if (!jobAssetNameFilters[metadata.jobId]) {
|
||||
jobAssetNameFilters[metadata.jobId] = []
|
||||
if (metadata?.promptId && asset.name) {
|
||||
if (!jobAssetNameFilters[metadata.promptId]) {
|
||||
jobAssetNameFilters[metadata.promptId] = []
|
||||
}
|
||||
if (!jobAssetNameFilters[metadata.jobId].includes(asset.name)) {
|
||||
jobAssetNameFilters[metadata.jobId].push(asset.name)
|
||||
if (!jobAssetNameFilters[metadata.promptId].includes(asset.name)) {
|
||||
jobAssetNameFilters[metadata.promptId].push(asset.name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -191,11 +191,11 @@ export function useMediaAssetActions() {
|
||||
if (!targetAsset) return
|
||||
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const jobId =
|
||||
metadata?.jobId ||
|
||||
const promptId =
|
||||
metadata?.promptId ||
|
||||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)
|
||||
|
||||
if (!jobId) {
|
||||
if (!promptId) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
@@ -205,7 +205,7 @@ export function useMediaAssetActions() {
|
||||
return
|
||||
}
|
||||
|
||||
await copyToClipboard(jobId)
|
||||
await copyToClipboard(promptId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@ function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
user_metadata: {
|
||||
jobId: 'job-1',
|
||||
promptId: 'prompt-1',
|
||||
nodeId: 'node-1',
|
||||
subfolder: 'outputs'
|
||||
},
|
||||
@@ -74,7 +74,7 @@ describe('useOutputStacks', () => {
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job-1' }),
|
||||
expect.objectContaining({ promptId: 'prompt-1' }),
|
||||
{
|
||||
createdAt: parent.created_at,
|
||||
excludeOutputKey: 'node-1-outputs-parent.png'
|
||||
|
||||
@@ -19,25 +19,25 @@ type UseOutputStacksOptions = {
|
||||
}
|
||||
|
||||
export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
const expandedStackJobIds = ref<Set<string>>(new Set())
|
||||
const stackChildrenByJobId = ref<Record<string, AssetItem[]>>({})
|
||||
const loadingStackJobIds = ref<Set<string>>(new Set())
|
||||
const expandedStackPromptIds = ref<Set<string>>(new Set())
|
||||
const stackChildrenByPromptId = ref<Record<string, AssetItem[]>>({})
|
||||
const loadingStackPromptIds = ref<Set<string>>(new Set())
|
||||
|
||||
const assetItems = computed<OutputStackListItem[]>(() => {
|
||||
const items: OutputStackListItem[] = []
|
||||
|
||||
for (const asset of assets.value) {
|
||||
const jobId = getStackJobId(asset)
|
||||
const promptId = getStackPromptId(asset)
|
||||
items.push({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
})
|
||||
|
||||
if (!jobId || !expandedStackJobIds.value.has(jobId)) {
|
||||
if (!promptId || !expandedStackPromptIds.value.has(promptId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const children = stackChildrenByJobId.value[jobId] ?? []
|
||||
const children = stackChildrenByPromptId.value[promptId] ?? []
|
||||
for (const child of children) {
|
||||
items.push({
|
||||
key: `asset-${child.id}`,
|
||||
@@ -54,55 +54,55 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
assetItems.value.map((item) => item.asset)
|
||||
)
|
||||
|
||||
function getStackJobId(asset: AssetItem): string | null {
|
||||
function getStackPromptId(asset: AssetItem): string | null {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
return metadata?.jobId ?? null
|
||||
return metadata?.promptId ?? null
|
||||
}
|
||||
|
||||
function isStackExpanded(asset: AssetItem): boolean {
|
||||
const jobId = getStackJobId(asset)
|
||||
if (!jobId) return false
|
||||
return expandedStackJobIds.value.has(jobId)
|
||||
const promptId = getStackPromptId(asset)
|
||||
if (!promptId) return false
|
||||
return expandedStackPromptIds.value.has(promptId)
|
||||
}
|
||||
|
||||
async function toggleStack(asset: AssetItem) {
|
||||
const jobId = getStackJobId(asset)
|
||||
if (!jobId) return
|
||||
const promptId = getStackPromptId(asset)
|
||||
if (!promptId) return
|
||||
|
||||
if (expandedStackJobIds.value.has(jobId)) {
|
||||
const next = new Set(expandedStackJobIds.value)
|
||||
next.delete(jobId)
|
||||
expandedStackJobIds.value = next
|
||||
if (expandedStackPromptIds.value.has(promptId)) {
|
||||
const next = new Set(expandedStackPromptIds.value)
|
||||
next.delete(promptId)
|
||||
expandedStackPromptIds.value = next
|
||||
return
|
||||
}
|
||||
|
||||
if (!stackChildrenByJobId.value[jobId]?.length) {
|
||||
if (loadingStackJobIds.value.has(jobId)) {
|
||||
if (!stackChildrenByPromptId.value[promptId]?.length) {
|
||||
if (loadingStackPromptIds.value.has(promptId)) {
|
||||
return
|
||||
}
|
||||
const nextLoading = new Set(loadingStackJobIds.value)
|
||||
nextLoading.add(jobId)
|
||||
loadingStackJobIds.value = nextLoading
|
||||
const nextLoading = new Set(loadingStackPromptIds.value)
|
||||
nextLoading.add(promptId)
|
||||
loadingStackPromptIds.value = nextLoading
|
||||
|
||||
const children = await resolveStackChildren(asset)
|
||||
|
||||
const afterLoading = new Set(loadingStackJobIds.value)
|
||||
afterLoading.delete(jobId)
|
||||
loadingStackJobIds.value = afterLoading
|
||||
const afterLoading = new Set(loadingStackPromptIds.value)
|
||||
afterLoading.delete(promptId)
|
||||
loadingStackPromptIds.value = afterLoading
|
||||
|
||||
if (!children.length) {
|
||||
return
|
||||
}
|
||||
|
||||
stackChildrenByJobId.value = {
|
||||
...stackChildrenByJobId.value,
|
||||
[jobId]: children
|
||||
stackChildrenByPromptId.value = {
|
||||
...stackChildrenByPromptId.value,
|
||||
[promptId]: children
|
||||
}
|
||||
}
|
||||
|
||||
const nextExpanded = new Set(expandedStackJobIds.value)
|
||||
nextExpanded.add(jobId)
|
||||
expandedStackJobIds.value = nextExpanded
|
||||
const nextExpanded = new Set(expandedStackPromptIds.value)
|
||||
nextExpanded.add(promptId)
|
||||
expandedStackPromptIds.value = nextExpanded
|
||||
}
|
||||
|
||||
async function resolveStackChildren(asset: AssetItem): Promise<AssetItem[]> {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
* Extends Record<string, unknown> for compatibility with AssetItem schema
|
||||
*/
|
||||
export interface OutputAssetMetadata extends Record<string, unknown> {
|
||||
jobId: string
|
||||
promptId: string
|
||||
nodeId: string | number
|
||||
subfolder: string
|
||||
executionTimeInSeconds?: number
|
||||
@@ -24,7 +24,7 @@ function isOutputAssetMetadata(
|
||||
): metadata is OutputAssetMetadata {
|
||||
if (!metadata) return false
|
||||
return (
|
||||
typeof metadata.jobId === 'string' &&
|
||||
typeof metadata.promptId === 'string' &&
|
||||
(typeof metadata.nodeId === 'string' || typeof metadata.nodeId === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,106 +1,423 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockDistributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: mockSettingStoreGet
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => {
|
||||
const registeredNodeTypes: Record<string, string> = {
|
||||
CheckpointLoaderSimple: 'ckpt_name',
|
||||
LoraLoader: 'lora_name'
|
||||
}
|
||||
return {
|
||||
useModelToNodeStore: vi.fn(() => ({
|
||||
getRegisteredNodeTypes: () => registeredNodeTypes,
|
||||
getCategoryForNodeType: vi.fn()
|
||||
}))
|
||||
}
|
||||
})
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
fetchApi: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
const mockGetCategoryForNodeType = vi.fn()
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: vi.fn(() => ({
|
||||
getRegisteredNodeTypes: vi.fn(() => ({
|
||||
CheckpointLoaderSimple: 'ckpt_name',
|
||||
LoraLoader: 'lora_name',
|
||||
VAELoader: 'vae_name',
|
||||
TestNode: ''
|
||||
})),
|
||||
getCategoryForNodeType: mockGetCategoryForNodeType,
|
||||
modelToNodeMap: {
|
||||
checkpoints: [{ nodeDef: { name: 'CheckpointLoaderSimple' } }],
|
||||
loras: [{ nodeDef: { name: 'LoraLoader' } }],
|
||||
vae: [{ nodeDef: { name: 'VAELoader' } }]
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
describe(assetService.shouldUseAssetBrowser, () => {
|
||||
// Helper to create API-compliant test assets
|
||||
function createTestAsset(overrides: Partial<AssetItem> = {}) {
|
||||
return {
|
||||
id: 'test-uuid',
|
||||
name: 'test-model.safetensors',
|
||||
asset_hash: 'blake3:test123',
|
||||
size: 123456,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
last_access_time: '2024-01-01T00:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Test data constants
|
||||
const MOCK_ASSETS = {
|
||||
checkpoints: createTestAsset({
|
||||
id: 'uuid-1',
|
||||
name: 'model1.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
loras: createTestAsset({
|
||||
id: 'uuid-2',
|
||||
name: 'model2.safetensors',
|
||||
tags: ['models', 'loras']
|
||||
}),
|
||||
vae: createTestAsset({
|
||||
id: 'uuid-3',
|
||||
name: 'vae1.safetensors',
|
||||
tags: ['models', 'vae']
|
||||
})
|
||||
} as const
|
||||
|
||||
// Helper functions
|
||||
function mockApiResponse(assets: unknown[], options = {}) {
|
||||
const response = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false,
|
||||
...options
|
||||
}
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce(Response.json(response))
|
||||
return response
|
||||
}
|
||||
|
||||
function mockApiError(status: number, statusText = 'Error') {
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce(
|
||||
new Response(null, { status, statusText })
|
||||
)
|
||||
}
|
||||
|
||||
describe('assetService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDistributionState.isCloud = false
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.resetAllMocks()
|
||||
vi.spyOn(api, 'fetchApi')
|
||||
})
|
||||
|
||||
it('returns false when not on cloud', () => {
|
||||
mockDistributionState.isCloud = false
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
describe('getAssetModelFolders', () => {
|
||||
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
|
||||
const assets = [
|
||||
createTestAsset({
|
||||
id: 'uuid-1',
|
||||
name: 'checkpoint1.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
createTestAsset({
|
||||
id: 'uuid-2',
|
||||
name: 'config.yaml',
|
||||
tags: ['models', 'configs'] // Blacklisted
|
||||
}),
|
||||
createTestAsset({
|
||||
id: 'uuid-3',
|
||||
name: 'vae1.safetensors',
|
||||
tags: ['models', 'vae']
|
||||
})
|
||||
]
|
||||
mockApiResponse(assets)
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
|
||||
).toBe(false)
|
||||
const result = await assetService.getAssetModelFolders()
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=500'
|
||||
)
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
const folderNames = result.map((f) => f.name)
|
||||
expect(folderNames).toEqual(['checkpoints', 'vae'])
|
||||
expect(folderNames).not.toContain('configs')
|
||||
})
|
||||
|
||||
it('should handle empty responses', async () => {
|
||||
mockApiResponse([])
|
||||
const emptyResult = await assetService.getAssetModelFolders()
|
||||
expect(emptyResult).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
|
||||
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
|
||||
'Network error'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle HTTP errors', async () => {
|
||||
mockApiError(500)
|
||||
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
|
||||
'Unable to load model folders: Server returned 500. Please try again.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false when asset API setting is disabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
describe('getAssetModels', () => {
|
||||
it('should return filtered models for folder', async () => {
|
||||
const assets = [
|
||||
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
|
||||
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
|
||||
createTestAsset({
|
||||
id: 'uuid-4',
|
||||
name: 'missing-model.safetensors',
|
||||
tags: ['models', 'checkpoints', 'missing'] // Has missing tag
|
||||
})
|
||||
]
|
||||
mockApiResponse(assets)
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
|
||||
).toBe(false)
|
||||
const result = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models%2Ccheckpoints&limit=500'
|
||||
)
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle errors and empty responses', async () => {
|
||||
// Empty response
|
||||
mockApiResponse([])
|
||||
const emptyResult = await assetService.getAssetModels('nonexistent')
|
||||
expect(emptyResult).toEqual([])
|
||||
|
||||
// Network error
|
||||
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
|
||||
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
|
||||
'Network error'
|
||||
)
|
||||
|
||||
// HTTP error
|
||||
mockApiError(404)
|
||||
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
|
||||
'Unable to load models for checkpoints: Server returned 404. Please try again.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false when node type is not eligible', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser('UnknownNode', 'some_input')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when cloud, setting enabled, and node is eligible', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when nodeType is undefined', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
|
||||
expect(assetService.shouldUseAssetBrowser(undefined, 'ckpt_name')).toBe(
|
||||
false
|
||||
describe('isAssetBrowserEligible', () => {
|
||||
it.for<[string, string, boolean, string]>([
|
||||
['CheckpointLoaderSimple', 'ckpt_name', true, 'valid inputs'],
|
||||
['LoraLoader', 'lora_name', true, 'valid inputs'],
|
||||
['VAELoader', 'vae_name', true, 'valid inputs'],
|
||||
['CheckpointLoaderSimple', 'type', false, 'other combo widgets'],
|
||||
['UnknownNode', 'widget', false, 'unregistered types'],
|
||||
['NotRegistered', 'widget', false, 'unregistered types']
|
||||
])(
|
||||
'isAssetBrowserEligible("%s", "%s") should return %s for %s',
|
||||
([type, name, expected]) => {
|
||||
expect(assetService.isAssetBrowserEligible(type, name)).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when widget name does not match registered input', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
describe('getAssetsForNodeType', () => {
|
||||
beforeEach(() => {
|
||||
mockGetCategoryForNodeType.mockClear()
|
||||
})
|
||||
|
||||
expect(
|
||||
assetService.shouldUseAssetBrowser(
|
||||
'CheckpointLoaderSimple',
|
||||
'wrong_input'
|
||||
it('should return empty array for unregistered node types', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||
|
||||
const result = await assetService.getAssetsForNodeType('UnknownNode')
|
||||
|
||||
expect(mockGetCategoryForNodeType).toHaveBeenCalledWith('UnknownNode')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should use getCategoryForNodeType for efficient category lookup', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||
const testAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsForNodeType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
expect(mockGetCategoryForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
|
||||
// Verify API call includes correct category (comma is URL-encoded by URLSearchParams)
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models%2Ccheckpoints&limit=500'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return empty array when no category found', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||
|
||||
const result = await assetService.getAssetsForNodeType('TestNode')
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue('loras')
|
||||
mockApiError(500, 'Internal Server Error')
|
||||
|
||||
await expect(
|
||||
assetService.getAssetsForNodeType('LoraLoader')
|
||||
).rejects.toThrow(
|
||||
'Unable to load assets for LoraLoader: Server returned 500. Please try again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return all assets without filtering for different categories', async () => {
|
||||
// Test checkpoints
|
||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||
const checkpointAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(checkpointAssets)
|
||||
|
||||
let result = await assetService.getAssetsForNodeType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(result).toEqual(checkpointAssets)
|
||||
|
||||
// Test loras
|
||||
mockGetCategoryForNodeType.mockReturnValue('loras')
|
||||
const loraAssets = [MOCK_ASSETS.loras]
|
||||
mockApiResponse(loraAssets)
|
||||
|
||||
result = await assetService.getAssetsForNodeType('LoraLoader')
|
||||
expect(result).toEqual(loraAssets)
|
||||
|
||||
// Test vae
|
||||
mockGetCategoryForNodeType.mockReturnValue('vae')
|
||||
const vaeAssets = [MOCK_ASSETS.vae]
|
||||
mockApiResponse(vaeAssets)
|
||||
|
||||
result = await assetService.getAssetsForNodeType('VAELoader')
|
||||
expect(result).toEqual(vaeAssets)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetsByTag', () => {
|
||||
it('should fetch assets with correct tag query parameter', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints, MOCK_ASSETS.loras]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('models')
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=500&include_public=true'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should filter out assets with missing tag', async () => {
|
||||
const testAssets = [
|
||||
MOCK_ASSETS.checkpoints,
|
||||
createTestAsset({
|
||||
id: 'uuid-missing',
|
||||
name: 'missing.safetensors',
|
||||
tags: ['models', 'checkpoints', 'missing']
|
||||
}),
|
||||
MOCK_ASSETS.loras
|
||||
]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('models')
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toEqual([MOCK_ASSETS.checkpoints, MOCK_ASSETS.loras])
|
||||
expect(result.some((a) => a.id === 'uuid-missing')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return empty array on API error', async () => {
|
||||
mockApiError(500)
|
||||
|
||||
await expect(assetService.getAssetsByTag('models')).rejects.toThrow(
|
||||
'Unable to load assets for tag models: Server returned 500. Please try again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return empty array for empty response', async () => {
|
||||
mockApiResponse([])
|
||||
|
||||
const result = await assetService.getAssetsByTag('nonexistent')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return AssetItem[] with full metadata', async () => {
|
||||
const fullAsset = createTestAsset({
|
||||
id: 'test-full',
|
||||
name: 'full-model.safetensors',
|
||||
asset_hash: 'blake3:full123',
|
||||
size: 999999,
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: { filename: 'models/checkpoints/full-model.safetensors' }
|
||||
})
|
||||
mockApiResponse([fullAsset])
|
||||
|
||||
const result = await assetService.getAssetsByTag('models')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual(fullAsset)
|
||||
expect(result[0]).toHaveProperty('asset_hash', 'blake3:full123')
|
||||
expect(result[0]).toHaveProperty('user_metadata')
|
||||
})
|
||||
|
||||
it('should exclude public assets when includePublic is false', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('input', false)
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=input&limit=500&include_public=false'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should include public assets when includePublic is true', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints, MOCK_ASSETS.loras]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('models', true)
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=500&include_public=true'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should accept custom limit via options', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('input', false, {
|
||||
limit: 100
|
||||
})
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=input&limit=100&include_public=false'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should accept custom offset via options', async () => {
|
||||
const testAssets = [MOCK_ASSETS.loras]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('models', true, {
|
||||
offset: 50
|
||||
})
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=500&offset=50&include_public=true'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should accept both limit and offset via options', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('input', false, {
|
||||
limit: 100,
|
||||
offset: 25
|
||||
})
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=input&limit=100&offset=25&include_public=false'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,8 +18,6 @@ import type {
|
||||
ModelFolder,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
@@ -300,29 +298,6 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the asset API is enabled (cloud environment + user setting).
|
||||
*/
|
||||
function isAssetAPIEnabled(): boolean {
|
||||
if (!isCloud) return false
|
||||
return !!useSettingStore().get('Comfy.Assets.UseAssetAPI')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the asset browser should be used for a given node input.
|
||||
* Combines the cloud environment check, user setting, and eligibility check.
|
||||
*
|
||||
* @param nodeType - The ComfyUI node comfyClass
|
||||
* @param widgetName - The name of the widget to check
|
||||
* @returns true if this input should use the asset browser
|
||||
*/
|
||||
function shouldUseAssetBrowser(
|
||||
nodeType: string | undefined,
|
||||
widgetName: string
|
||||
): boolean {
|
||||
return isAssetAPIEnabled() && isAssetBrowserEligible(nodeType, widgetName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets assets for a specific node type by finding the matching category
|
||||
* and fetching all assets with that category tag
|
||||
@@ -757,9 +732,7 @@ function createAssetService() {
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
isAssetAPIEnabled,
|
||||
isAssetBrowserEligible,
|
||||
shouldUseAssetBrowser,
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
@@ -68,7 +66,9 @@ export function createAssetWidget(
|
||||
if (!validatedAsset.success) {
|
||||
console.error(
|
||||
'Invalid asset item:',
|
||||
fromZodError(validatedAsset.error).message
|
||||
validatedAsset.error.errors,
|
||||
'Received:',
|
||||
asset
|
||||
)
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
url: 'https://example.com/b.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-1',
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
executionTimeInSeconds: 12.5,
|
||||
@@ -66,7 +66,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'job-1-1-sub-a.png',
|
||||
id: 'prompt-1-1-sub-a.png',
|
||||
name: 'a.png',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
tags: ['output'],
|
||||
@@ -75,7 +75,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
)
|
||||
expect(results[0].user_metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
jobId: 'job-1',
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
executionTimeInSeconds: 12.5
|
||||
@@ -95,7 +95,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
url: 'https://example.com/full.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-2',
|
||||
promptId: 'prompt-2',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 3,
|
||||
@@ -111,7 +111,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(mocks.getJobDetail).toHaveBeenCalledWith('job-2')
|
||||
expect(mocks.getJobDetail).toHaveBeenCalledWith('prompt-2')
|
||||
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
|
||||
jobDetail
|
||||
)
|
||||
@@ -129,7 +129,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
url: 'https://example.com/root.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-root',
|
||||
promptId: 'prompt-root',
|
||||
nodeId: '1',
|
||||
subfolder: '',
|
||||
outputCount: 1,
|
||||
@@ -144,7 +144,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
if (!asset) {
|
||||
throw new Error('Expected a root output asset')
|
||||
}
|
||||
expect(asset.id).toBe('job-root-1--root.png')
|
||||
expect(asset.id).toBe('prompt-root-1--root.png')
|
||||
if (!asset.user_metadata) {
|
||||
throw new Error('Expected output metadata')
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type OutputAssetMapOptions = {
|
||||
jobId: string
|
||||
promptId: string
|
||||
outputs: readonly ResultItemImpl[]
|
||||
createdAt?: string
|
||||
executionTimeInSeconds?: number
|
||||
@@ -51,7 +51,7 @@ export function getOutputKey({
|
||||
}
|
||||
|
||||
function mapOutputsToAssetItems({
|
||||
jobId,
|
||||
promptId,
|
||||
outputs,
|
||||
createdAt,
|
||||
executionTimeInSeconds,
|
||||
@@ -67,14 +67,14 @@ function mapOutputsToAssetItems({
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: `${jobId}-${outputKey}`,
|
||||
id: `${promptId}-${outputKey}`,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: createdAtValue,
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: {
|
||||
jobId,
|
||||
promptId,
|
||||
nodeId: output.nodeId,
|
||||
subfolder: output.subfolder,
|
||||
executionTimeInSeconds,
|
||||
@@ -92,7 +92,7 @@ export async function resolveOutputAssetItems(
|
||||
): Promise<AssetItem[]> {
|
||||
let outputsToDisplay = metadata.allOutputs ?? []
|
||||
if (shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
|
||||
const jobDetail = await getJobDetail(metadata.jobId)
|
||||
const jobDetail = await getJobDetail(metadata.promptId)
|
||||
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
|
||||
if (previewableOutputs.length) {
|
||||
outputsToDisplay = previewableOutputs
|
||||
@@ -100,7 +100,7 @@ export async function resolveOutputAssetItems(
|
||||
}
|
||||
|
||||
return mapOutputsToAssetItems({
|
||||
jobId: metadata.jobId,
|
||||
promptId: metadata.promptId,
|
||||
outputs: outputsToDisplay,
|
||||
createdAt,
|
||||
executionTimeInSeconds: metadata.executionTimeInSeconds,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { JobId } from '@/schemas/apiSchema'
|
||||
import type { PromptId } from '@/schemas/apiSchema'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
@@ -119,19 +119,19 @@ export async function fetchQueue(
|
||||
*/
|
||||
export async function fetchJobDetail(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
jobId: JobId
|
||||
promptId: PromptId
|
||||
): Promise<JobDetail | undefined> {
|
||||
try {
|
||||
const res = await fetchApi(`/jobs/${encodeURIComponent(jobId)}`)
|
||||
const res = await fetchApi(`/jobs/${encodeURIComponent(promptId)}`)
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`Job not found for job ${jobId}`)
|
||||
console.warn(`Job not found for prompt ${promptId}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return zJobDetail.parse(await res.json())
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch job detail for job ${jobId}:`, error)
|
||||
console.error(`Failed to fetch job detail for prompt ${promptId}:`, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1148,7 +1148,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
tooltip:
|
||||
'Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering.',
|
||||
defaultValue: false,
|
||||
defaultsByInstallVersion: { '1.41.0': isCloud },
|
||||
sortOrder: 100,
|
||||
experimental: true,
|
||||
versionAdded: '1.27.1'
|
||||
@@ -1237,7 +1236,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
tooltip:
|
||||
'When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
defaultValue: false,
|
||||
experimental: true,
|
||||
versionAdded: '1.40.0'
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const mockWorkflow: ComfyWorkflowJSON = {
|
||||
// Jobs API detail response structure (matches actual /jobs/{id} response)
|
||||
// workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
|
||||
const mockJobDetailResponse: JobDetail = {
|
||||
id: 'test-job-id',
|
||||
id: 'test-prompt-id',
|
||||
status: 'completed',
|
||||
create_time: 1234567890,
|
||||
update_time: 1234567900,
|
||||
@@ -43,15 +43,15 @@ const mockJobDetailResponse: JobDetail = {
|
||||
}
|
||||
|
||||
describe('fetchJobDetail', () => {
|
||||
it('should fetch job detail from /jobs/{job_id} endpoint', async () => {
|
||||
it('should fetch job detail from /jobs/{prompt_id} endpoint', async () => {
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockJobDetailResponse
|
||||
})
|
||||
|
||||
await fetchJobDetail(mockFetchApi, 'test-job-id')
|
||||
await fetchJobDetail(mockFetchApi, 'test-prompt-id')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/jobs/test-job-id')
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/jobs/test-prompt-id')
|
||||
})
|
||||
|
||||
it('should return job detail with workflow and outputs', async () => {
|
||||
@@ -60,10 +60,10 @@ describe('fetchJobDetail', () => {
|
||||
json: async () => mockJobDetailResponse
|
||||
})
|
||||
|
||||
const result = await fetchJobDetail(mockFetchApi, 'test-job-id')
|
||||
const result = await fetchJobDetail(mockFetchApi, 'test-prompt-id')
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.id).toBe('test-job-id')
|
||||
expect(result?.id).toBe('test-prompt-id')
|
||||
expect(result?.outputs).toEqual(mockJobDetailResponse.outputs)
|
||||
expect(result?.workflow).toBeDefined()
|
||||
})
|
||||
@@ -82,7 +82,7 @@ describe('fetchJobDetail', () => {
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const result = await fetchJobDetail(mockFetchApi, 'test-job-id')
|
||||
const result = await fetchJobDetail(mockFetchApi, 'test-prompt-id')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
@@ -95,7 +95,7 @@ describe('fetchJobDetail', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const result = await fetchJobDetail(mockFetchApi, 'test-job-id')
|
||||
const result = await fetchJobDetail(mockFetchApi, 'test-prompt-id')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import { getJobWorkflow } from '@/services/jobOutputCache'
|
||||
* @returns WorkflowSource with workflow and generated filename
|
||||
*
|
||||
* @example
|
||||
* const asset = { name: 'output.png', user_metadata: { jobId: '123' } }
|
||||
* const asset = { name: 'output.png', user_metadata: { promptId: '123' } }
|
||||
* const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
||||
*/
|
||||
export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
|
||||
@@ -30,8 +30,8 @@ export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
|
||||
|
||||
// For output assets: use jobs API (with caching and validation)
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (metadata?.jobId) {
|
||||
const workflow = await getJobWorkflow(metadata.jobId)
|
||||
if (metadata?.promptId) {
|
||||
const workflow = await getJobWorkflow(metadata.promptId)
|
||||
return { workflow: workflow ?? null, filename: baseFilename }
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
@@ -30,7 +30,6 @@ const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
@@ -102,11 +101,7 @@ const partitionedNodes = computed(() => {
|
||||
})
|
||||
|
||||
const batchCountWidget: SimplifiedWidget<number> = {
|
||||
options: {
|
||||
precision: 0,
|
||||
min: 1,
|
||||
max: settingStore.get('Comfy.QueueButton.BatchCountLimit')
|
||||
},
|
||||
options: { precision: 0, min: 1, max: isCloud ? 4 : 99 },
|
||||
value: 1,
|
||||
name: t('linearMode.runCount'),
|
||||
type: 'number'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
@@ -13,7 +12,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
const Preview3d = () => import('@/renderer/extensions/linearMode/Preview3d.vue')
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
@@ -23,7 +21,6 @@ import {
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import type { StatItem } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration } from '@/utils/dateTimeUtil'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
@@ -31,22 +28,15 @@ import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const { runButtonClick } = defineProps<{
|
||||
const { runButtonClick, selectedItem, selectedOutput } = defineProps<{
|
||||
latentPreview?: string
|
||||
runButtonClick?: (e: Event) => void
|
||||
selectedItem?: AssetItem
|
||||
selectedOutput?: ResultItemImpl
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const selectedItem = ref<AssetItem>()
|
||||
const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
whenever(
|
||||
() => nodeOutputStore.latestPreview[0],
|
||||
() => (latentPreview.value = nodeOutputStore.latestPreview[0])
|
||||
)
|
||||
|
||||
const dateOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -65,17 +55,16 @@ function formatTime(time?: string) {
|
||||
}
|
||||
|
||||
const itemStats = computed<StatItem[]>(() => {
|
||||
if (!selectedItem.value) return []
|
||||
const user_metadata = getOutputAssetMetadata(selectedItem.value.user_metadata)
|
||||
if (!selectedItem) return []
|
||||
const user_metadata = getOutputAssetMetadata(selectedItem.user_metadata)
|
||||
if (!user_metadata) return []
|
||||
|
||||
const { allOutputs } = user_metadata
|
||||
return [
|
||||
{ content: formatTime(selectedItem.value.created_at) },
|
||||
{ content: formatTime(selectedItem.created_at) },
|
||||
{ content: formatDuration(user_metadata.executionTimeInSeconds) },
|
||||
allOutputs && { content: t('g.asset', allOutputs.length) },
|
||||
(selectedOutput.value && mediaTypes[getMediaType(selectedOutput.value)]) ??
|
||||
{}
|
||||
(selectedOutput && mediaTypes[getMediaType(selectedOutput)]) ?? {}
|
||||
].filter((i) => !!i)
|
||||
})
|
||||
|
||||
@@ -100,7 +89,7 @@ async function loadWorkflow(item: AssetItem | undefined) {
|
||||
|
||||
async function rerun(e: Event) {
|
||||
if (!runButtonClick) return
|
||||
await loadWorkflow(selectedItem.value)
|
||||
await loadWorkflow(selectedItem)
|
||||
//FIXME don't use timeouts here
|
||||
//Currently seeds fail to properly update even with timeouts?
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
@@ -147,28 +136,28 @@ async function rerun(e: Event) {
|
||||
</Button>
|
||||
<Popover
|
||||
:entries="[
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll'),
|
||||
command: () => downloadAsset(selectedItem!)
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('queue.jobMenu.deleteAsset'),
|
||||
command: () => mediaActions.deleteAssets(selectedItem!)
|
||||
}
|
||||
[
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll'),
|
||||
action: () => downloadAsset(selectedItem!)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('queue.jobMenu.deleteAsset'),
|
||||
action: () => mediaActions.deleteAssets(selectedItem!)
|
||||
}
|
||||
]
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<ImagePreview
|
||||
v-if="
|
||||
(canShowPreview && latentPreview) ||
|
||||
getMediaType(selectedOutput) === 'images'
|
||||
"
|
||||
v-if="latentPreview ?? getMediaType(selectedOutput) === 'images'"
|
||||
:mobile
|
||||
:src="(canShowPreview && latentPreview) || selectedOutput!.url"
|
||||
:src="latentPreview ?? selectedOutput!.url"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(selectedOutput) === 'video'"
|
||||
@@ -191,12 +180,4 @@ async function rerun(e: Event) {
|
||||
:model-url="selectedOutput!.url"
|
||||
/>
|
||||
<LinearWelcome v-else />
|
||||
<OutputHistory
|
||||
@update-selection="
|
||||
(event) => {
|
||||
;[selectedItem, selectedOutput, canShowPreview] = event
|
||||
latentPreview = undefined
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
import {
|
||||
useAsyncState,
|
||||
useEventListener,
|
||||
useInfiniteScroll
|
||||
useInfiniteScroll,
|
||||
useScroll
|
||||
} from '@vueuse/core'
|
||||
import { computed, ref, toRaw, toValue, useTemplateRef, watch } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarIcon from '@/components/sidebar/SidebarIcon.vue'
|
||||
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
@@ -20,8 +27,10 @@ import {
|
||||
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useQueueStore, ResultItemImpl } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const displayWorkflows = ref(false)
|
||||
const outputs = useMediaAssets('output')
|
||||
const {
|
||||
progressBarContainerClass,
|
||||
@@ -31,15 +40,26 @@ const {
|
||||
} = useProgressBarBackground()
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const workflowTab = useWorkspaceStore()
|
||||
.getSidebarTabs()
|
||||
.find((w) => w.id === 'workflows')
|
||||
|
||||
void outputs.fetchMediaList()
|
||||
|
||||
defineProps<{
|
||||
scrollResetButtonTo?: string | HTMLElement
|
||||
mobile?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
updateSelection: [
|
||||
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
|
||||
]
|
||||
}>()
|
||||
|
||||
defineExpose({ onWheel })
|
||||
|
||||
const selectedIndex = ref<[number, number]>([-1, 0])
|
||||
|
||||
function doEmit() {
|
||||
@@ -52,9 +72,17 @@ function doEmit() {
|
||||
}
|
||||
|
||||
const outputsRef = useTemplateRef('outputsRef')
|
||||
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
||||
canLoadMore: () => outputs.hasMore.value
|
||||
})
|
||||
const { reset: resetInfiniteScroll } = useInfiniteScroll(
|
||||
outputsRef,
|
||||
outputs.loadMore,
|
||||
{ canLoadMore: () => outputs.hasMore.value }
|
||||
)
|
||||
function resetOutputsScroll() {
|
||||
//TODO need to also prune outputs entries?
|
||||
resetInfiniteScroll()
|
||||
outputsRef.value?.scrollTo(0, 0)
|
||||
}
|
||||
const { y: outputScrollState } = useScroll(outputsRef)
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
const [index, key] = selectedIndex.value
|
||||
@@ -107,7 +135,7 @@ function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
|
||||
return user_metadata.allOutputs
|
||||
|
||||
const outputRef = useAsyncState(
|
||||
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
||||
getJobDetail(user_metadata.promptId).then((jobDetail) => {
|
||||
if (!jobDetail?.outputs) return []
|
||||
return Object.entries(jobDetail.outputs).flatMap(flattenNodeOutput)
|
||||
}),
|
||||
@@ -180,31 +208,26 @@ function gotoPreviousOutput() {
|
||||
|
||||
let pointer = new CanvasPointer(document.body)
|
||||
let scrollOffset = 0
|
||||
useEventListener(
|
||||
document.body,
|
||||
'wheel',
|
||||
function (e: WheelEvent) {
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!pointer.isTrackpadGesture(e)) {
|
||||
if (e.deltaY > 0) gotoNextOutput()
|
||||
else gotoPreviousOutput()
|
||||
return
|
||||
}
|
||||
scrollOffset += e.deltaY
|
||||
while (scrollOffset >= 60) {
|
||||
scrollOffset -= 60
|
||||
gotoNextOutput()
|
||||
}
|
||||
while (scrollOffset <= -60) {
|
||||
scrollOffset += 60
|
||||
gotoPreviousOutput()
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false }
|
||||
)
|
||||
if (!pointer.isTrackpadGesture(e)) {
|
||||
if (e.deltaY > 0) gotoNextOutput()
|
||||
else gotoPreviousOutput()
|
||||
return
|
||||
}
|
||||
scrollOffset += e.deltaY
|
||||
while (scrollOffset >= 60) {
|
||||
scrollOffset -= 60
|
||||
gotoNextOutput()
|
||||
}
|
||||
while (scrollOffset <= -60) {
|
||||
scrollOffset += 60
|
||||
gotoPreviousOutput()
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -221,80 +244,138 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<article
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="h-24 p-3 overflow-x-auto overflow-y-clip border-node-component-border flex items-center contain-size md:mx-15"
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'min-w-38 flex bg-comfy-menu-bg md:h-full border-border-subtle',
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'right'
|
||||
? 'flex-row-reverse border-l'
|
||||
: 'md:border-r'
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<section
|
||||
v-if="
|
||||
queueStore.runningTasks.length > 0 || queueStore.pendingTasks.length > 0
|
||||
"
|
||||
data-testid="linear-job"
|
||||
class="py-3 h-24 aspect-square px-1 relative"
|
||||
<div
|
||||
v-if="!mobile"
|
||||
class="h-full flex flex-col w-14 shrink-0 overflow-hidden items-center p-2"
|
||||
>
|
||||
<i
|
||||
v-if="queueStore.runningTasks.length > 0"
|
||||
class="icon-[lucide--loader-circle] size-full animate-spin"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--ellipsis] size-full animate-pulse" />
|
||||
<div
|
||||
v-if="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
|
||||
"
|
||||
class="absolute top-0 right-0 p-1 min-w-5 h-5 flex justify-center items-center rounded-full bg-primary-background text-text-primary"
|
||||
v-text="queueStore.runningTasks.length + queueStore.pendingTasks.length"
|
||||
/>
|
||||
<div class="absolute -bottom-1 w-full h-3 rounded-sm overflow-clip">
|
||||
<div :class="progressBarContainerClass">
|
||||
<div
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(totalPercent)"
|
||||
/>
|
||||
<div
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(currentNodePercent)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<template v-for="(item, index) in outputs.media.value" :key="index">
|
||||
<div class="border-border-subtle border-l first:border-none h-21 m-3" />
|
||||
<template v-for="(output, key) in toValue(allOutputs(item))" :key>
|
||||
<img
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square object-cover h-20',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
:data-output-index="index"
|
||||
:src="output.url"
|
||||
@click="selectedIndex = [index, key]"
|
||||
<template v-if="workflowTab">
|
||||
<SidebarIcon
|
||||
:icon="workflowTab.icon"
|
||||
:icon-badge="workflowTab.iconBadge"
|
||||
:tooltip="workflowTab.tooltip"
|
||||
:label="workflowTab.label || workflowTab.title"
|
||||
:class="workflowTab.id + '-tab-button'"
|
||||
:selected="displayWorkflows"
|
||||
:is-small="settingStore.get('Comfy.Sidebar.Size') === 'small'"
|
||||
@click="displayWorkflows = !displayWorkflows"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square w-full',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
:data-output-index="index"
|
||||
@click="selectedIndex = [index, key]"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</article>
|
||||
<SidebarTemplatesButton />
|
||||
<div class="flex-1" />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<div class="border-border-subtle md:border-r" />
|
||||
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50 grow-1" />
|
||||
<article
|
||||
v-else
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="h-24 md:h-full min-w-24 grow-1 p-3 overflow-x-auto overflow-y-clip md:overflow-y-auto md:overflow-x-clip md:border-r-1 border-node-component-border flex md:flex-col items-center contain-size"
|
||||
>
|
||||
<section
|
||||
v-if="
|
||||
queueStore.runningTasks.length > 0 ||
|
||||
queueStore.pendingTasks.length > 0
|
||||
"
|
||||
data-testid="linear-job"
|
||||
class="py-3 not-md:h-24 md:w-full aspect-square px-1 relative"
|
||||
>
|
||||
<i
|
||||
v-if="queueStore.runningTasks.length > 0"
|
||||
class="icon-[lucide--loader-circle] size-full animate-spin"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--ellipsis] size-full animate-pulse" />
|
||||
<div
|
||||
v-if="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
|
||||
"
|
||||
class="absolute top-0 right-0 p-1 min-w-5 h-5 flex justify-center items-center rounded-full bg-primary-background text-text-primary"
|
||||
v-text="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length
|
||||
"
|
||||
/>
|
||||
<div class="absolute -bottom-1 w-full h-3 rounded-sm overflow-clip">
|
||||
<div :class="progressBarContainerClass">
|
||||
<div
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(totalPercent)"
|
||||
/>
|
||||
<div
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(currentNodePercent)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<template v-for="(item, index) in outputs.media.value" :key="index">
|
||||
<div
|
||||
class="border-border-subtle not-md:border-l md:border-t first:border-none not-md:h-21 md:w-full m-3"
|
||||
/>
|
||||
<template v-for="(output, key) in toValue(allOutputs(item))" :key>
|
||||
<img
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square object-cover not-md:h-20 md:w-full',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
:data-output-index="index"
|
||||
:src="output.url"
|
||||
@click="selectedIndex = [index, key]"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square w-full',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
:data-output-index="index"
|
||||
@click="selectedIndex = [index, key]"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</article>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="outputScrollState && scrollResetButtonTo"
|
||||
:to="scrollResetButtonTo"
|
||||
>
|
||||
<Button
|
||||
:class="
|
||||
cn(
|
||||
'p-3 size-10 bg-base-foreground',
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
? 'left-4'
|
||||
: 'right-4'
|
||||
)
|
||||
"
|
||||
@click="resetOutputsScroll"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-up] size-4 bg-base-background" />
|
||||
</Button>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="minimapRef"
|
||||
:class="
|
||||
cn(
|
||||
'minimap-main-container absolute right-0 bottom-[54px] z-1000 flex',
|
||||
isMobile ? 'flex-col' : 'flex-row'
|
||||
)
|
||||
"
|
||||
class="minimap-main-container absolute right-0 bottom-[54px] z-1000 flex"
|
||||
>
|
||||
<MiniMapPanel
|
||||
v-if="showOptionsPanel"
|
||||
@@ -17,7 +12,6 @@
|
||||
:show-groups="showGroups"
|
||||
:render-bypass="renderBypass"
|
||||
:render-error="renderError"
|
||||
:is-mobile="isMobile"
|
||||
@update-option="updateOption"
|
||||
/>
|
||||
|
||||
@@ -76,18 +70,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import MiniMapPanel from './MiniMapPanel.vue'
|
||||
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const minimapRef = ref<HTMLDivElement>()
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'minimap-panel flex flex-col gap-2 bg-comfy-menu-bg p-3 text-sm shadow-interface',
|
||||
isMobile ? 'mb-2' : 'mr-2'
|
||||
)
|
||||
"
|
||||
class="minimap-panel mr-2 flex flex-col gap-2 bg-comfy-menu-bg p-3 text-sm shadow-interface"
|
||||
:style="panelStyles"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -84,10 +79,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
import type { MinimapSettingsKey } from '@/renderer/extensions/minimap/types'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
panelStyles: CSSProperties
|
||||
@@ -96,7 +90,6 @@ defineProps<{
|
||||
showGroups: boolean
|
||||
renderBypass: boolean
|
||||
renderError: boolean
|
||||
isMobile: Ref<boolean> | boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -134,8 +134,11 @@
|
||||
as-child
|
||||
>
|
||||
<button
|
||||
v-if="hasAnyError && showErrorsTabEnabled"
|
||||
@click.stop="useRightSidePanelStore().openPanel('errors')"
|
||||
v-if="hasAnyError"
|
||||
@click.stop="
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
|
||||
useRightSidePanelStore().openPanel('error')
|
||||
"
|
||||
>
|
||||
<span>{{ t('g.error') }}</span>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
@@ -307,10 +310,6 @@ const hasAnyError = computed((): boolean => {
|
||||
)
|
||||
})
|
||||
|
||||
const showErrorsTabEnabled = computed(() =>
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
)
|
||||
|
||||
const displayHeader = computed(() => nodeData.titleMode !== TitleMode.NO_TITLE)
|
||||
|
||||
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
<div
|
||||
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
|
||||
:class="isEditing === false ? 'visible' : 'invisible'"
|
||||
tabindex="0"
|
||||
data-capture-wheel="true"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
|
||||
|
||||
@@ -15,16 +15,27 @@ const i18n = createI18n({
|
||||
})
|
||||
|
||||
// Mock modules
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
shouldUseAssetBrowser: vi.fn(() => true),
|
||||
isAssetAPIEnabled: vi.fn(() => true)
|
||||
isAssetBrowserEligible: vi.fn(() => true)
|
||||
}
|
||||
}))
|
||||
|
||||
const mockSettingStoreGet = vi.fn()
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: mockSettingStoreGet
|
||||
}))
|
||||
}))
|
||||
|
||||
// Import after mocks are defined
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
const mockShouldUseAssetBrowser = vi.mocked(assetService.shouldUseAssetBrowser)
|
||||
const mockAssetServiceEligible = vi.mocked(assetService.isAssetBrowserEligible)
|
||||
|
||||
describe('WidgetSelect asset mode', () => {
|
||||
const createWidget = (): SimplifiedWidget<string | undefined> => ({
|
||||
@@ -38,7 +49,8 @@ describe('WidgetSelect asset mode', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockShouldUseAssetBrowser.mockReturnValue(true)
|
||||
mockAssetServiceEligible.mockReturnValue(true)
|
||||
mockSettingStoreGet.mockReturnValue(true) // Default to true for UseAssetAPI
|
||||
})
|
||||
|
||||
// Helper to mount with common setup
|
||||
@@ -64,8 +76,17 @@ describe('WidgetSelect asset mode', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses default widget when shouldUseAssetBrowser returns false', () => {
|
||||
mockShouldUseAssetBrowser.mockReturnValue(false)
|
||||
it('uses default widget when UseAssetAPI setting is false', () => {
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
const wrapper = mountWidget()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'WidgetSelectDefault' }).exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses default widget when node is not eligible', () => {
|
||||
mockAssetServiceEligible.mockReturnValue(false)
|
||||
const wrapper = mountWidget()
|
||||
|
||||
expect(
|
||||
|
||||
@@ -18,21 +18,35 @@ import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/Widg
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
|
||||
// Mock state for asset service
|
||||
const mockShouldUseAssetBrowser = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
|
||||
// Mock state for distribution and settings
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockIsAssetBrowserEligible = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockDistributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: mockSettingStoreGet
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
shouldUseAssetBrowser: mockShouldUseAssetBrowser,
|
||||
isAssetAPIEnabled: mockIsAssetAPIEnabled
|
||||
isAssetBrowserEligible: mockIsAssetBrowserEligible
|
||||
}
|
||||
}))
|
||||
|
||||
describe('WidgetSelect Value Binding', () => {
|
||||
beforeEach(() => {
|
||||
mockShouldUseAssetBrowser.mockReturnValue(false)
|
||||
mockIsAssetAPIEnabled.mockReturnValue(false)
|
||||
// Reset all mocks before each test
|
||||
mockDistributionState.isCloud = false
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
mockIsAssetBrowserEligible.mockReturnValue(false)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -249,8 +263,10 @@ describe('WidgetSelect Value Binding', () => {
|
||||
})
|
||||
|
||||
describe('Asset mode detection', () => {
|
||||
it('enables asset mode when shouldUseAssetBrowser returns true', () => {
|
||||
mockShouldUseAssetBrowser.mockReturnValue(true)
|
||||
it('enables asset mode when all conditions are met', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
mockIsAssetBrowserEligible.mockReturnValue(true)
|
||||
|
||||
const widget = createMockWidget('test.safetensors')
|
||||
const wrapper = mount(WidgetSelect, {
|
||||
@@ -268,8 +284,8 @@ describe('WidgetSelect Value Binding', () => {
|
||||
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disables asset mode when shouldUseAssetBrowser returns false', () => {
|
||||
mockShouldUseAssetBrowser.mockReturnValue(false)
|
||||
it('disables asset mode when conditions are not met', () => {
|
||||
mockDistributionState.isCloud = false
|
||||
|
||||
const widget = createMockWidget('test.safetensors')
|
||||
const wrapper = mount(WidgetSelect, {
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||
@@ -109,11 +111,19 @@ const specDescriptor = computed<{
|
||||
}
|
||||
})
|
||||
|
||||
const isAssetMode = computed(
|
||||
() =>
|
||||
assetService.shouldUseAssetBrowser(props.nodeType, props.widget.name) ||
|
||||
(assetService.isAssetAPIEnabled() && props.widget.type === 'asset')
|
||||
)
|
||||
const isAssetMode = computed(() => {
|
||||
if (isCloud) {
|
||||
const settingStore = useSettingStore()
|
||||
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
const isEligible =
|
||||
assetService.isAssetBrowserEligible(props.nodeType, props.widget.name) ||
|
||||
props.widget.type === 'asset'
|
||||
|
||||
return isUsingAssetAPI && isEligible
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const assetKind = computed(() => specDescriptor.value.kind)
|
||||
const isDropdownUIWidget = computed(
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
|
||||
:placeholder
|
||||
:readonly="isReadOnly"
|
||||
:disabled="isReadOnly"
|
||||
fluid
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.capture.stop
|
||||
|
||||
@@ -74,8 +74,7 @@ vi.mock('@/i18n', () => ({
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
isAssetBrowserEligible: vi.fn(() => false),
|
||||
shouldUseAssetBrowser: vi.fn(() => false)
|
||||
isAssetBrowserEligible: vi.fn(() => false)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -136,7 +135,6 @@ describe('useComboWidget', () => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(false)
|
||||
vi.mocked(useAssetBrowserDialog).mockClear()
|
||||
mockDistributionState.isCloud = false
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
@@ -167,8 +165,8 @@ describe('useComboWidget', () => {
|
||||
|
||||
it('should create normal combo widget when asset API is disabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(false)
|
||||
mockSettingStoreGet.mockReturnValue(false) // Asset API disabled
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
@@ -189,12 +187,14 @@ describe('useComboWidget', () => {
|
||||
expect.any(Function),
|
||||
{ values: ['model1.safetensors', 'model2.safetensors'] }
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
@@ -218,7 +218,8 @@ describe('useComboWidget', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(vi.mocked(assetService.shouldUseAssetBrowser)).toHaveBeenCalledWith(
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name'
|
||||
)
|
||||
@@ -227,7 +228,8 @@ describe('useComboWidget', () => {
|
||||
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
@@ -252,12 +254,14 @@ describe('useComboWidget', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should show Select model when asset widget has undefined current value', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
@@ -281,6 +285,7 @@ describe('useComboWidget', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
InputSpec
|
||||
@@ -90,7 +91,6 @@ function createAssetBrowserWidget(
|
||||
node,
|
||||
widgetName: inputSpec.name,
|
||||
nodeTypeForBrowser: node.comfyClass ?? '',
|
||||
inputNameForBrowser: inputSpec.name,
|
||||
defaultValue,
|
||||
onValueChange: (widget, newValue, oldValue) => {
|
||||
node.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
|
||||
@@ -177,7 +177,14 @@ const addComboWidget = (
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
|
||||
if (isCloud) {
|
||||
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
|
||||
const settingStore = useSettingStore()
|
||||
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
const isEligible = assetService.isAssetBrowserEligible(
|
||||
node.comfyClass,
|
||||
inputSpec.name
|
||||
)
|
||||
|
||||
if (isUsingAssetAPI && isEligible) {
|
||||
return createAssetBrowserWidget(node, inputSpec, defaultValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
|
||||
const zNodeType = z.string()
|
||||
const zJobId = z.string()
|
||||
export type JobId = z.infer<typeof zJobId>
|
||||
const zPromptId = z.string()
|
||||
export type PromptId = z.infer<typeof zPromptId>
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
|
||||
@@ -52,7 +52,7 @@ const zStatusWsMessage = z.object({
|
||||
const zProgressWsMessage = z.object({
|
||||
value: z.number().int(),
|
||||
max: z.number().int(),
|
||||
prompt_id: zJobId,
|
||||
prompt_id: zPromptId,
|
||||
node: zNodeId
|
||||
})
|
||||
|
||||
@@ -61,21 +61,21 @@ const zNodeProgressState = z.object({
|
||||
max: z.number(),
|
||||
state: z.enum(['pending', 'running', 'finished', 'error']),
|
||||
node_id: zNodeId,
|
||||
prompt_id: zJobId,
|
||||
prompt_id: zPromptId,
|
||||
display_node_id: zNodeId.optional(),
|
||||
parent_node_id: zNodeId.optional(),
|
||||
real_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zProgressStateWsMessage = z.object({
|
||||
prompt_id: zJobId,
|
||||
prompt_id: zPromptId,
|
||||
nodes: z.record(zNodeId, zNodeProgressState)
|
||||
})
|
||||
|
||||
const zExecutingWsMessage = z.object({
|
||||
node: zNodeId,
|
||||
display_node: zNodeId,
|
||||
prompt_id: zJobId
|
||||
prompt_id: zPromptId
|
||||
})
|
||||
|
||||
const zExecutedWsMessage = zExecutingWsMessage.extend({
|
||||
@@ -84,7 +84,7 @@ const zExecutedWsMessage = zExecutingWsMessage.extend({
|
||||
})
|
||||
|
||||
const zExecutionWsMessageBase = z.object({
|
||||
prompt_id: zJobId,
|
||||
prompt_id: zPromptId,
|
||||
timestamp: z.number().int()
|
||||
})
|
||||
|
||||
|
||||
@@ -157,14 +157,14 @@ interface BackendApiCalls {
|
||||
logs: LogsWsMessage
|
||||
/** Binary preview/progress data */
|
||||
b_preview: Blob
|
||||
/** Binary preview with metadata (node_id, job_id) */
|
||||
/** Binary preview with metadata (node_id, prompt_id) */
|
||||
b_preview_with_metadata: {
|
||||
blob: Blob
|
||||
nodeId: string
|
||||
parentNodeId: string
|
||||
displayNodeId: string
|
||||
realNodeId: string
|
||||
jobId: string
|
||||
promptId: string
|
||||
}
|
||||
progress_text: ProgressTextWsMessage
|
||||
progress_state: ProgressStateWsMessage
|
||||
@@ -646,7 +646,7 @@ export class ComfyApi extends EventTarget {
|
||||
displayNodeId: metadata.display_node_id,
|
||||
parentNodeId: metadata.parent_node_id,
|
||||
realNodeId: metadata.real_node_id,
|
||||
jobId: metadata.prompt_id
|
||||
promptId: metadata.prompt_id
|
||||
})
|
||||
|
||||
// Also dispatch legacy b_preview for backward compatibility
|
||||
@@ -831,20 +831,7 @@ export class ComfyApi extends EventTarget {
|
||||
})
|
||||
|
||||
if (res.status !== 200) {
|
||||
const text = await res.text()
|
||||
let errorResponse
|
||||
try {
|
||||
errorResponse = JSON.parse(text)
|
||||
} catch {
|
||||
errorResponse = {
|
||||
error: {
|
||||
type: 'server_error',
|
||||
message: `${res.status} ${res.statusText}`,
|
||||
details: text
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new PromptExecutionError(errorResponse)
|
||||
throw new PromptExecutionError(await res.json())
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
@@ -956,7 +943,7 @@ export class ComfyApi extends EventTarget {
|
||||
|
||||
/**
|
||||
* Gets detailed job info including outputs and workflow
|
||||
* @param jobId The job ID
|
||||
* @param jobId The job/prompt ID
|
||||
* @returns Full job details or undefined if not found
|
||||
*/
|
||||
async getJobDetail(jobId: string): Promise<JobDetail | undefined> {
|
||||
@@ -1009,14 +996,14 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupts the execution of the running job. If runningJobId is provided,
|
||||
* Interrupts the execution of the running prompt. If runningPromptId is provided,
|
||||
* it is included in the payload as a helpful hint to the backend.
|
||||
* @param {string | null} [runningJobId] Optional Running Job ID to interrupt
|
||||
* @param {string | null} [runningPromptId] Optional Running Prompt ID to interrupt
|
||||
*/
|
||||
async interrupt(runningJobId: string | null) {
|
||||
async interrupt(runningPromptId: string | null) {
|
||||
await this._postItem(
|
||||
'interrupt',
|
||||
runningJobId ? { prompt_id: runningJobId } : undefined
|
||||
runningPromptId ? { prompt_id: runningPromptId } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import type { ExtensionManager } from '@/types/extensionTypes'
|
||||
@@ -712,21 +713,23 @@ export class ComfyApp {
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
}
|
||||
} else if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
useExecutionStore().showErrorOverlay()
|
||||
} else {
|
||||
useDialogService().showExecutionErrorDialog(detail)
|
||||
}
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
this.canvas.deselectAll()
|
||||
useRightSidePanelStore().openPanel('errors')
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
})
|
||||
|
||||
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
|
||||
// Enhanced preview with explicit node context
|
||||
const { blob, displayNodeId, jobId } = detail
|
||||
const { blob, displayNodeId, promptId } = detail
|
||||
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
|
||||
useNodeOutputStore()
|
||||
const blobUrl = createSharedObjectUrl(blob)
|
||||
useJobPreviewStore().setPreviewUrl(jobId, blobUrl)
|
||||
useJobPreviewStore().setPreviewUrl(promptId, blobUrl)
|
||||
// Ensure clean up if `executing` event is missed.
|
||||
revokePreviewsByExecutionId(displayNodeId)
|
||||
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
|
||||
@@ -1443,7 +1446,7 @@ export class ComfyApp {
|
||||
} else {
|
||||
try {
|
||||
if (res.prompt_id) {
|
||||
executionStore.storeJob({
|
||||
executionStore.storePrompt({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
workflow: useWorkspaceStore().workflow
|
||||
@@ -1462,10 +1465,7 @@ export class ComfyApp {
|
||||
{}) as MissingNodeTypeExtraInfo
|
||||
const missingNodeType = createMissingNodeTypeFromError(extraInfo)
|
||||
this.showMissingNodesError([missingNodeType])
|
||||
} else if (
|
||||
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
|
||||
!(error instanceof PromptExecutionError)
|
||||
) {
|
||||
} else {
|
||||
useDialogService().showErrorDialog(error, {
|
||||
title: t('errorDialog.promptExecutionError'),
|
||||
reportType: 'promptExecutionError'
|
||||
@@ -1500,8 +1500,11 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection and open the error panel so the user can immediately
|
||||
// see the error details without extra clicks.
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
executionStore.showErrorOverlay()
|
||||
this.canvas.deselectAll()
|
||||
useRightSidePanelStore().openPanel('errors')
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
}
|
||||
|
||||
@@ -339,7 +339,7 @@ export class ChangeTracker {
|
||||
api.addEventListener('executed', (e: CustomEvent<ExecutedWsMessage>) => {
|
||||
const detail = e.detail
|
||||
const workflow =
|
||||
useExecutionStore().queuedJobs[detail.prompt_id]?.workflow
|
||||
useExecutionStore().queuedPrompts[detail.prompt_id]?.workflow
|
||||
const changeTracker = workflow?.changeTracker
|
||||
if (!changeTracker) return
|
||||
changeTracker.nodeOutputs ??= {}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
isInstantRunningMode,
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueSettingsStore
|
||||
} from '@/stores/queueStore'
|
||||
@@ -30,7 +29,7 @@ export function setupAutoQueueHandler() {
|
||||
internalCount = queueCountStore.count
|
||||
if (!internalCount && !app.lastExecutionError) {
|
||||
if (
|
||||
isInstantRunningMode(queueSettingsStore.mode) ||
|
||||
queueSettingsStore.mode === 'instant' ||
|
||||
(queueSettingsStore.mode === 'change' && graphHasChanged)
|
||||
) {
|
||||
graphHasChanged = false
|
||||
|
||||
@@ -46,7 +46,7 @@ export function findActiveIndex(
|
||||
export async function getOutputsForTask(
|
||||
task: TaskItemImpl
|
||||
): Promise<ResultItemImpl[] | null> {
|
||||
const requestId = String(task.jobId)
|
||||
const requestId = String(task.promptId)
|
||||
latestTaskRequestId = requestId
|
||||
|
||||
const outputsCount = task.outputsCount ?? 0
|
||||
|
||||
@@ -90,10 +90,10 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
url: string
|
||||
}
|
||||
| undefined
|
||||
public jobId: string
|
||||
public promptId: string
|
||||
|
||||
constructor(public job: JobListItem) {
|
||||
this.jobId = job.id
|
||||
this.promptId = job.id
|
||||
this.flatOutputs = [
|
||||
{
|
||||
supportsPreview: true,
|
||||
@@ -123,9 +123,9 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
preview_url: `http://test.com/${name}`
|
||||
})),
|
||||
mapTaskOutputToAssetItem: vi.fn((task, output) => {
|
||||
const index = parseInt(task.jobId.split('_')[1]) || 0
|
||||
const index = parseInt(task.promptId.split('_')[1]) || 0
|
||||
return {
|
||||
id: task.jobId,
|
||||
id: task.promptId,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
describe('useExecutionStore - reconcileInitializingPrompts', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -141,36 +141,36 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should remove job IDs not present in active jobs', () => {
|
||||
store.initializingJobIds = new Set(['job-1', 'job-2', 'job-3'])
|
||||
it('should remove prompt IDs not present in active jobs', () => {
|
||||
store.initializingPromptIds = new Set(['job-1', 'job-2', 'job-3'])
|
||||
|
||||
store.reconcileInitializingJobs(new Set(['job-1']))
|
||||
store.reconcileInitializingPrompts(new Set(['job-1']))
|
||||
|
||||
expect(store.initializingJobIds).toEqual(new Set(['job-1']))
|
||||
expect(store.initializingPromptIds).toEqual(new Set(['job-1']))
|
||||
})
|
||||
|
||||
it('should be a no-op when all initializing IDs are active', () => {
|
||||
store.initializingJobIds = new Set(['job-1', 'job-2'])
|
||||
store.initializingPromptIds = new Set(['job-1', 'job-2'])
|
||||
|
||||
store.reconcileInitializingJobs(new Set(['job-1', 'job-2', 'job-3']))
|
||||
store.reconcileInitializingPrompts(new Set(['job-1', 'job-2', 'job-3']))
|
||||
|
||||
expect(store.initializingJobIds).toEqual(new Set(['job-1', 'job-2']))
|
||||
expect(store.initializingPromptIds).toEqual(new Set(['job-1', 'job-2']))
|
||||
})
|
||||
|
||||
it('should be a no-op when there are no initializing jobs', () => {
|
||||
store.initializingJobIds = new Set()
|
||||
it('should be a no-op when there are no initializing prompts', () => {
|
||||
store.initializingPromptIds = new Set()
|
||||
|
||||
store.reconcileInitializingJobs(new Set(['job-1']))
|
||||
store.reconcileInitializingPrompts(new Set(['job-1']))
|
||||
|
||||
expect(store.initializingJobIds).toEqual(new Set())
|
||||
expect(store.initializingPromptIds).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('should clear all initializing IDs when no active jobs exist', () => {
|
||||
store.initializingJobIds = new Set(['job-1', 'job-2'])
|
||||
store.initializingPromptIds = new Set(['job-1', 'job-2'])
|
||||
|
||||
store.reconcileInitializingJobs(new Set())
|
||||
store.reconcileInitializingPrompts(new Set())
|
||||
|
||||
expect(store.initializingJobIds).toEqual(new Set())
|
||||
expect(store.initializingPromptIds).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { isEmpty } from 'es-toolkit/compat'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -35,9 +36,8 @@ import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
|
||||
interface QueuedJob {
|
||||
interface QueuedPrompt {
|
||||
/**
|
||||
* The nodes that are queued to be executed. The key is the node id and the
|
||||
* value is a boolean indicating if the node has been executed.
|
||||
@@ -111,23 +111,23 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const clientId = ref<string | null>(null)
|
||||
const activeJobId = ref<string | null>(null)
|
||||
const queuedJobs = ref<Record<NodeId, QueuedJob>>({})
|
||||
const activePromptId = ref<string | null>(null)
|
||||
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
const lastPromptError = ref<PromptError | null>(null)
|
||||
// This is the progress of all nodes in the currently executing workflow
|
||||
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
|
||||
const nodeProgressStatesByJob = ref<
|
||||
const nodeProgressStatesByPrompt = ref<
|
||||
Record<string, Record<string, NodeProgressState>>
|
||||
>({})
|
||||
|
||||
/**
|
||||
* Map of job ID to workflow ID for quick lookup across the app.
|
||||
* Map of prompt_id to workflow ID for quick lookup across the app.
|
||||
*/
|
||||
const jobIdToWorkflowId = ref<Map<string, string>>(new Map())
|
||||
const promptIdToWorkflowId = ref<Map<string, string>>(new Map())
|
||||
|
||||
const initializingJobIds = ref<Set<string>>(new Set())
|
||||
const initializingPromptIds = ref<Set<string>>(new Set())
|
||||
|
||||
const mergeExecutionProgressStates = (
|
||||
currentState: NodeProgressState | undefined,
|
||||
@@ -201,7 +201,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const executingNode = computed<ComfyNode | null>(() => {
|
||||
if (!executingNodeId.value) return null
|
||||
|
||||
const workflow: ComfyWorkflow | undefined = activeJob.value?.workflow
|
||||
const workflow: ComfyWorkflow | undefined = activePrompt.value?.workflow
|
||||
if (!workflow) return null
|
||||
|
||||
const canvasState: ComfyWorkflowJSON | null =
|
||||
@@ -222,24 +222,24 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
: null
|
||||
)
|
||||
|
||||
const activeJob = computed<QueuedJob | undefined>(
|
||||
() => queuedJobs.value[activeJobId.value ?? '']
|
||||
const activePrompt = computed<QueuedPrompt | undefined>(
|
||||
() => queuedPrompts.value[activePromptId.value ?? '']
|
||||
)
|
||||
|
||||
const totalNodesToExecute = computed<number>(() => {
|
||||
if (!activeJob.value) return 0
|
||||
return Object.values(activeJob.value.nodes).length
|
||||
if (!activePrompt.value) return 0
|
||||
return Object.values(activePrompt.value.nodes).length
|
||||
})
|
||||
|
||||
const isIdle = computed<boolean>(() => !activeJobId.value)
|
||||
const isIdle = computed<boolean>(() => !activePromptId.value)
|
||||
|
||||
const nodesExecuted = computed<number>(() => {
|
||||
if (!activeJob.value) return 0
|
||||
return Object.values(activeJob.value.nodes).filter(Boolean).length
|
||||
if (!activePrompt.value) return 0
|
||||
return Object.values(activePrompt.value.nodes).filter(Boolean).length
|
||||
})
|
||||
|
||||
const executionProgress = computed<number>(() => {
|
||||
if (!activeJob.value) return 0
|
||||
if (!activePrompt.value) return 0
|
||||
const total = totalNodesToExecute.value
|
||||
const done = nodesExecuted.value
|
||||
return total > 0 ? done / total : 0
|
||||
@@ -291,66 +291,65 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
lastNodeErrors.value = null
|
||||
isErrorOverlayOpen.value = false
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
clearInitializationByJobId(activeJobId.value)
|
||||
activePromptId.value = e.detail.prompt_id
|
||||
queuedPrompts.value[activePromptId.value] ??= { nodes: {} }
|
||||
clearInitializationByPromptId(activePromptId.value)
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
if (!activeJob.value) return
|
||||
if (!activePrompt.value) return
|
||||
for (const n of e.detail.nodes) {
|
||||
activeJob.value.nodes[n] = true
|
||||
activePrompt.value.nodes[n] = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleExecutionInterrupted(
|
||||
e: CustomEvent<ExecutionInterruptedWsMessage>
|
||||
) {
|
||||
const jobId = e.detail.prompt_id
|
||||
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
|
||||
resetExecutionState(jobId)
|
||||
const pid = e.detail.prompt_id
|
||||
if (activePromptId.value)
|
||||
clearInitializationByPromptId(activePromptId.value)
|
||||
resetExecutionState(pid)
|
||||
}
|
||||
|
||||
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
|
||||
if (!activeJob.value) return
|
||||
activeJob.value.nodes[e.detail.node] = true
|
||||
if (!activePrompt.value) return
|
||||
activePrompt.value.nodes[e.detail.node] = true
|
||||
}
|
||||
|
||||
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
|
||||
if (isCloud && activeJobId.value) {
|
||||
if (isCloud && activePromptId.value) {
|
||||
useTelemetry()?.trackExecutionSuccess({
|
||||
jobId: activeJobId.value
|
||||
jobId: activePromptId.value
|
||||
})
|
||||
}
|
||||
const jobId = e.detail.prompt_id
|
||||
resetExecutionState(jobId)
|
||||
const pid = e.detail.prompt_id
|
||||
resetExecutionState(pid)
|
||||
}
|
||||
|
||||
function handleExecuting(e: CustomEvent<NodeId | null>): void {
|
||||
// Clear the current node progress when a new node starts executing
|
||||
_executingNodeProgress.value = null
|
||||
|
||||
if (!activeJob.value) return
|
||||
if (!activePrompt.value) return
|
||||
|
||||
// Update the executing nodes list
|
||||
if (typeof e.detail !== 'string') {
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
activeJobId.value = null
|
||||
activePromptId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
|
||||
const { nodes, prompt_id: jobId } = e.detail
|
||||
const { nodes, prompt_id: pid } = e.detail
|
||||
|
||||
// Revoke previews for nodes that are starting to execute
|
||||
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
|
||||
const previousForPrompt = nodeProgressStatesByPrompt.value[pid] || {}
|
||||
for (const nodeId in nodes) {
|
||||
const nodeState = nodes[nodeId]
|
||||
if (nodeState.state === 'running' && !previousForJob[nodeId]) {
|
||||
if (nodeState.state === 'running' && !previousForPrompt[nodeId]) {
|
||||
// This node just started executing, revoke its previews
|
||||
// Note that we're doing the *actual* node id instead of the display node id
|
||||
// here intentionally. That way, we don't clear the preview every time a new node
|
||||
@@ -361,9 +360,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
// Update the progress states for all nodes
|
||||
nodeProgressStatesByJob.value = {
|
||||
...nodeProgressStatesByJob.value,
|
||||
[jobId]: nodes
|
||||
nodeProgressStatesByPrompt.value = {
|
||||
...nodeProgressStatesByPrompt.value,
|
||||
[pid]: nodes
|
||||
}
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
@@ -393,6 +392,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
|
||||
lastExecutionError.value = e.detail
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackExecutionError({
|
||||
jobId: e.detail.prompt_id,
|
||||
@@ -400,58 +400,14 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeType: e.detail.node_type,
|
||||
error: e.detail.exception_message
|
||||
})
|
||||
|
||||
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
|
||||
if (handleCloudValidationError(e.detail)) return
|
||||
}
|
||||
|
||||
// Service-level errors (e.g. "Job has stagnated") have no associated node.
|
||||
// Route them as job errors
|
||||
if (handleServiceLevelError(e.detail)) return
|
||||
|
||||
// OSS path / Cloud fallback (real runtime errors)
|
||||
lastExecutionError.value = e.detail
|
||||
clearInitializationByJobId(e.detail.prompt_id)
|
||||
clearInitializationByPromptId(e.detail.prompt_id)
|
||||
resetExecutionState(e.detail.prompt_id)
|
||||
}
|
||||
|
||||
function handleServiceLevelError(detail: ExecutionErrorWsMessage): boolean {
|
||||
const nodeId = detail.node_id
|
||||
if (nodeId !== null && nodeId !== undefined && String(nodeId) !== '')
|
||||
return false
|
||||
|
||||
clearInitializationByJobId(detail.prompt_id)
|
||||
resetExecutionState(detail.prompt_id)
|
||||
lastPromptError.value = {
|
||||
type: detail.exception_type ?? 'error',
|
||||
message: detail.exception_type
|
||||
? `${detail.exception_type}: ${detail.exception_message}`
|
||||
: (detail.exception_message ?? ''),
|
||||
details: detail.traceback?.join('\n') ?? ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleCloudValidationError(
|
||||
detail: ExecutionErrorWsMessage
|
||||
): boolean {
|
||||
const result = classifyCloudValidationError(detail.exception_message)
|
||||
if (!result) return false
|
||||
|
||||
clearInitializationByJobId(detail.prompt_id)
|
||||
resetExecutionState(detail.prompt_id)
|
||||
|
||||
if (result.kind === 'nodeErrors') {
|
||||
lastNodeErrors.value = result.nodeErrors
|
||||
} else {
|
||||
lastPromptError.value = result.promptError
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification handler used for frontend/cloud initialization tracking.
|
||||
* Marks a job as initializing when cloud notifies it is waiting for a machine.
|
||||
* Marks a prompt as initializing when cloud notifies it is waiting for a machine.
|
||||
*/
|
||||
function handleNotification(e: CustomEvent<NotificationWsMessage>) {
|
||||
const payload = e.detail
|
||||
@@ -460,60 +416,62 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (!id) return
|
||||
// Until cloud implements a proper message
|
||||
if (text.includes('Waiting for a machine')) {
|
||||
const next = new Set(initializingJobIds.value)
|
||||
const next = new Set(initializingPromptIds.value)
|
||||
next.add(id)
|
||||
initializingJobIds.value = next
|
||||
initializingPromptIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function clearInitializationByJobId(jobId: string | null) {
|
||||
if (!jobId) return
|
||||
if (!initializingJobIds.value.has(jobId)) return
|
||||
const next = new Set(initializingJobIds.value)
|
||||
next.delete(jobId)
|
||||
initializingJobIds.value = next
|
||||
function clearInitializationByPromptId(promptId: string | null) {
|
||||
if (!promptId) return
|
||||
if (!initializingPromptIds.value.has(promptId)) return
|
||||
const next = new Set(initializingPromptIds.value)
|
||||
next.delete(promptId)
|
||||
initializingPromptIds.value = next
|
||||
}
|
||||
|
||||
function clearInitializationByJobIds(jobIds: string[]) {
|
||||
if (!jobIds.length) return
|
||||
const current = initializingJobIds.value
|
||||
const toRemove = jobIds.filter((id) => current.has(id))
|
||||
function clearInitializationByPromptIds(promptIds: string[]) {
|
||||
if (!promptIds.length) return
|
||||
const current = initializingPromptIds.value
|
||||
const toRemove = promptIds.filter((id) => current.has(id))
|
||||
if (!toRemove.length) return
|
||||
const next = new Set(current)
|
||||
for (const id of toRemove) {
|
||||
next.delete(id)
|
||||
}
|
||||
initializingJobIds.value = next
|
||||
initializingPromptIds.value = next
|
||||
}
|
||||
|
||||
function reconcileInitializingJobs(activeJobIds: Set<string>) {
|
||||
const orphaned = [...initializingJobIds.value].filter(
|
||||
function reconcileInitializingPrompts(activeJobIds: Set<string>) {
|
||||
const orphaned = [...initializingPromptIds.value].filter(
|
||||
(id) => !activeJobIds.has(id)
|
||||
)
|
||||
clearInitializationByJobIds(orphaned)
|
||||
clearInitializationByPromptIds(orphaned)
|
||||
}
|
||||
|
||||
function isJobInitializing(jobId: string | number | undefined): boolean {
|
||||
if (!jobId) return false
|
||||
return initializingJobIds.value.has(String(jobId))
|
||||
function isPromptInitializing(
|
||||
promptId: string | number | undefined
|
||||
): boolean {
|
||||
if (!promptId) return false
|
||||
return initializingPromptIds.value.has(String(promptId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset execution-related state after a run completes or is stopped.
|
||||
*/
|
||||
function resetExecutionState(jobIdParam?: string | null) {
|
||||
function resetExecutionState(pid?: string | null) {
|
||||
nodeProgressStates.value = {}
|
||||
const jobId = jobIdParam ?? activeJobId.value ?? null
|
||||
if (jobId) {
|
||||
const map = { ...nodeProgressStatesByJob.value }
|
||||
delete map[jobId]
|
||||
nodeProgressStatesByJob.value = map
|
||||
useJobPreviewStore().clearPreview(jobId)
|
||||
const promptId = pid ?? activePromptId.value ?? null
|
||||
if (promptId) {
|
||||
const map = { ...nodeProgressStatesByPrompt.value }
|
||||
delete map[promptId]
|
||||
nodeProgressStatesByPrompt.value = map
|
||||
useJobPreviewStore().clearPreview(promptId)
|
||||
}
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
activeJobId.value = null
|
||||
activePromptId.value = null
|
||||
_executingNodeProgress.value = null
|
||||
lastPromptError.value = null
|
||||
}
|
||||
@@ -537,7 +495,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
useNodeProgressText().showTextPreview(node, text)
|
||||
}
|
||||
|
||||
function storeJob({
|
||||
function storePrompt({
|
||||
nodes,
|
||||
id,
|
||||
workflow
|
||||
@@ -546,28 +504,31 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
id: string
|
||||
workflow: ComfyWorkflow
|
||||
}) {
|
||||
queuedJobs.value[id] ??= { nodes: {} }
|
||||
const queuedJob = queuedJobs.value[id]
|
||||
queuedJob.nodes = {
|
||||
queuedPrompts.value[id] ??= { nodes: {} }
|
||||
const queuedPrompt = queuedPrompts.value[id]
|
||||
queuedPrompt.nodes = {
|
||||
...nodes.reduce((p: Record<string, boolean>, n) => {
|
||||
p[n] = false
|
||||
return p
|
||||
}, {}),
|
||||
...queuedJob.nodes
|
||||
...queuedPrompt.nodes
|
||||
}
|
||||
queuedJob.workflow = workflow
|
||||
queuedPrompt.workflow = workflow
|
||||
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
|
||||
if (wid) {
|
||||
jobIdToWorkflowId.value.set(String(id), String(wid))
|
||||
promptIdToWorkflowId.value.set(String(id), String(wid))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or update a mapping from job ID to workflow ID.
|
||||
* Register or update a mapping from prompt_id to workflow ID.
|
||||
*/
|
||||
function registerJobWorkflowIdMapping(jobId: string, workflowId: string) {
|
||||
if (!jobId || !workflowId) return
|
||||
jobIdToWorkflowId.value.set(String(jobId), String(workflowId))
|
||||
function registerPromptWorkflowIdMapping(
|
||||
promptId: string,
|
||||
workflowId: string
|
||||
) {
|
||||
if (!promptId || !workflowId) return
|
||||
promptIdToWorkflowId.value.set(String(promptId), String(workflowId))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -582,9 +543,11 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return executionId
|
||||
}
|
||||
|
||||
const runningJobIds = computed<string[]>(() => {
|
||||
const runningPromptIds = computed<string[]>(() => {
|
||||
const result: string[] = []
|
||||
for (const [pid, nodes] of Object.entries(nodeProgressStatesByJob.value)) {
|
||||
for (const [pid, nodes] of Object.entries(
|
||||
nodeProgressStatesByPrompt.value
|
||||
)) {
|
||||
if (Object.values(nodes).some((n) => n.state === 'running')) {
|
||||
result.push(pid)
|
||||
}
|
||||
@@ -593,7 +556,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
})
|
||||
|
||||
const runningWorkflowCount = computed<number>(
|
||||
() => runningJobIds.value.length
|
||||
() => runningPromptIds.value.length
|
||||
)
|
||||
|
||||
/** Map of node errors indexed by locator ID. */
|
||||
@@ -698,7 +661,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
/** Whether any node validation errors are present */
|
||||
const hasNodeError = computed(
|
||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||
() => !!lastNodeErrors.value && !isEmpty(lastNodeErrors.value)
|
||||
)
|
||||
|
||||
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
|
||||
@@ -706,49 +669,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value
|
||||
)
|
||||
|
||||
const allErrorExecutionIds = computed<string[]>(() => {
|
||||
const ids: string[] = []
|
||||
if (lastNodeErrors.value) {
|
||||
ids.push(...Object.keys(lastNodeErrors.value))
|
||||
}
|
||||
if (lastExecutionError.value) {
|
||||
const nodeId = lastExecutionError.value.node_id
|
||||
if (nodeId !== null && nodeId !== undefined) {
|
||||
ids.push(String(nodeId))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
/** Count of prompt-level errors (0 or 1) */
|
||||
const promptErrorCount = computed(() => (lastPromptError.value ? 1 : 0))
|
||||
|
||||
/** Count of all individual node validation errors */
|
||||
const nodeErrorCount = computed(() => {
|
||||
if (!lastNodeErrors.value) return 0
|
||||
let count = 0
|
||||
for (const nodeError of Object.values(lastNodeErrors.value)) {
|
||||
count += nodeError.errors.length
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Count of runtime execution errors (0 or 1) */
|
||||
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
|
||||
|
||||
/** Total count of all individual errors */
|
||||
const totalErrorCount = computed(
|
||||
() =>
|
||||
promptErrorCount.value + nodeErrorCount.value + executionErrorCount.value
|
||||
)
|
||||
|
||||
/** Pre-computed Set of graph node IDs (as strings) that have errors in the current graph scope. */
|
||||
/** Pre-computed Set of graph node IDs (as strings) that have errors. */
|
||||
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!app.rootGraph) return ids
|
||||
|
||||
// Fall back to rootGraph when currentGraph hasn't been initialized yet
|
||||
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
|
||||
const activeGraph = useCanvasStore().currentGraph ?? app.rootGraph
|
||||
|
||||
if (lastNodeErrors.value) {
|
||||
for (const executionId of Object.keys(lastNodeErrors.value)) {
|
||||
@@ -770,36 +696,19 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return ids
|
||||
})
|
||||
|
||||
function hasInternalErrorForNode(nodeId: string | number): boolean {
|
||||
const prefix = `${nodeId}:`
|
||||
return allErrorExecutionIds.value.some((id) => id.startsWith(prefix))
|
||||
}
|
||||
|
||||
const isErrorOverlayOpen = ref(false)
|
||||
|
||||
function showErrorOverlay() {
|
||||
isErrorOverlayOpen.value = true
|
||||
}
|
||||
|
||||
function dismissErrorOverlay() {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
activeJobId,
|
||||
queuedJobs,
|
||||
activePromptId,
|
||||
queuedPrompts,
|
||||
lastNodeErrors,
|
||||
lastExecutionError,
|
||||
lastPromptError,
|
||||
hasAnyError,
|
||||
allErrorExecutionIds,
|
||||
totalErrorCount,
|
||||
lastExecutionErrorNodeId,
|
||||
executingNodeId,
|
||||
executingNodeIds,
|
||||
activeJob,
|
||||
activePrompt,
|
||||
totalNodesToExecute,
|
||||
nodesExecuted,
|
||||
executionProgress,
|
||||
@@ -807,32 +716,28 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
executingNodeProgress,
|
||||
nodeProgressStates,
|
||||
nodeLocationProgressStates,
|
||||
nodeProgressStatesByJob,
|
||||
runningJobIds,
|
||||
nodeProgressStatesByPrompt,
|
||||
runningPromptIds,
|
||||
runningWorkflowCount,
|
||||
initializingJobIds,
|
||||
isJobInitializing,
|
||||
clearInitializationByJobId,
|
||||
clearInitializationByJobIds,
|
||||
reconcileInitializingJobs,
|
||||
initializingPromptIds,
|
||||
isPromptInitializing,
|
||||
clearInitializationByPromptId,
|
||||
clearInitializationByPromptIds,
|
||||
reconcileInitializingPrompts,
|
||||
bindExecutionEvents,
|
||||
unbindExecutionEvents,
|
||||
storeJob,
|
||||
registerJobWorkflowIdMapping,
|
||||
storePrompt,
|
||||
registerPromptWorkflowIdMapping,
|
||||
uniqueExecutingNodeIdStrings,
|
||||
// Raw executing progress data for backward compatibility in ComfyApp.
|
||||
_executingNodeProgress,
|
||||
// NodeLocatorId conversion helpers
|
||||
executionIdToNodeLocatorId,
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId,
|
||||
promptIdToWorkflowId,
|
||||
// Node error lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError,
|
||||
hasInternalErrorForNode,
|
||||
activeGraphErrorNodeIds,
|
||||
isErrorOverlayOpen,
|
||||
showErrorOverlay,
|
||||
dismissErrorOverlay
|
||||
activeGraphErrorNodeIds
|
||||
}
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ const EXPECTED_DEFAULT_TYPES = [
|
||||
|
||||
type NodeDefStoreType = ReturnType<typeof useNodeDefStore>
|
||||
|
||||
// Create minimal but valid ComfyNodeDefImpl for testing
|
||||
function createMockNodeDef(name: string): ComfyNodeDefImpl {
|
||||
const def: ComfyNodeDefV1 = {
|
||||
name,
|
||||
@@ -81,6 +82,7 @@ const MOCK_NODE_NAMES = [
|
||||
'FL_ChatterboxTurboTTS',
|
||||
'FL_ChatterboxMultilingualTTS',
|
||||
'FL_ChatterboxVC',
|
||||
// New extension node mappings
|
||||
'LatentUpscaleModelLoader',
|
||||
'DownloadAndLoadSAM2Model',
|
||||
'SAMLoader',
|
||||
@@ -96,6 +98,8 @@ const mockNodeDefsByName = Object.fromEntries(
|
||||
MOCK_NODE_NAMES.map((name) => [name, createMockNodeDef(name)])
|
||||
)
|
||||
|
||||
// Mock nodeDefStore dependency - modelToNodeStore relies on this for registration
|
||||
// Most tests expect this to be populated; tests that need empty state can override
|
||||
vi.mock('@/stores/nodeDefStore', async (importOriginal) => {
|
||||
const original = await importOriginal<NodeDefStoreType>()
|
||||
|
||||
@@ -135,6 +139,7 @@ describe('useModelToNodeStore', () => {
|
||||
|
||||
const provider = modelToNodeStore.getNodeProvider('checkpoints')
|
||||
expect(provider).toBeDefined()
|
||||
// After asserting provider is defined, we can safely access its properties
|
||||
expect(provider?.nodeDef?.name).toBe('CheckpointLoaderSimple')
|
||||
expect(provider?.key).toBe('ckpt_name')
|
||||
})
|
||||
@@ -150,6 +155,7 @@ describe('useModelToNodeStore', () => {
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
const provider = modelToNodeStore.getNodeProvider('checkpoints')
|
||||
// Using optional chaining for safety since getNodeProvider() can return undefined
|
||||
expect(provider?.nodeDef?.name).toBe('CheckpointLoaderSimple')
|
||||
})
|
||||
|
||||
@@ -353,6 +359,7 @@ describe('useModelToNodeStore', () => {
|
||||
|
||||
const retrieved = modelToNodeStore.getNodeProvider('custom_type')
|
||||
expect(retrieved).toStrictEqual(customProvider)
|
||||
// Optional chaining for consistency with getNodeProvider() return type
|
||||
expect(retrieved?.key).toBe('custom_key')
|
||||
})
|
||||
|
||||
@@ -401,6 +408,7 @@ describe('useModelToNodeStore', () => {
|
||||
|
||||
const provider = modelToNodeStore.getNodeProvider('test_type')
|
||||
expect(provider).toBeDefined()
|
||||
// After asserting provider is defined, we can safely access its properties
|
||||
expect(provider!.nodeDef.name).toBe('UNETLoader')
|
||||
expect(provider!.key).toBe('test_param')
|
||||
})
|
||||
@@ -466,6 +474,7 @@ describe('useModelToNodeStore', () => {
|
||||
})
|
||||
|
||||
it('should not register when nodeDefStore is empty', () => {
|
||||
// Create fresh Pinia for this test to avoid state persistence
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
|
||||
@@ -475,6 +484,7 @@ describe('useModelToNodeStore', () => {
|
||||
modelToNodeStore.registerDefaults()
|
||||
expect(modelToNodeStore.getNodeProvider('checkpoints')).toBeUndefined()
|
||||
|
||||
// Restore original mock for subsequent tests
|
||||
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
|
||||
nodeDefsByName: mockNodeDefsByName
|
||||
})
|
||||
@@ -489,6 +499,7 @@ describe('useModelToNodeStore', () => {
|
||||
})
|
||||
|
||||
it('should return empty Record when nodeDefStore is empty', () => {
|
||||
// Create fresh Pinia for this test to avoid state persistence
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
|
||||
@@ -499,6 +510,7 @@ describe('useModelToNodeStore', () => {
|
||||
const result = modelToNodeStore.getRegisteredNodeTypes()
|
||||
expect(result).toStrictEqual({})
|
||||
|
||||
// Restore original mock for subsequent tests
|
||||
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
|
||||
nodeDefsByName: mockNodeDefsByName
|
||||
})
|
||||
@@ -589,6 +601,7 @@ describe('useModelToNodeStore', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
// These should not throw but return undefined
|
||||
expect(modelToNodeStore.getCategoryForNodeType(null!)).toBeUndefined()
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType(undefined!)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user