Compare commits

..

48 Commits

Author SHA1 Message Date
bymyself
1d6b7437db fix: resolve merge conflict with main (draftTypes.ts deleted on both sides)
Amp-Thread-ID: https://ampcode.com/threads/T-019c79f4-bca4-768d-a3d0-7d612f7186ac
2026-02-20 00:44:13 -08:00
Christian Byrne
bad9642fad Review fixes for V2 Node Search (#8987) (#9001)
Addresses code review feedback from both manual review and CodeRabbit on
#8987.

**18 commits, one per fix:**

### Fixes (11)
- Add `migrateDeprecatedValue` for removed `'simple'` setting option
- Extract magic number and use semantic color token in NodePreviewCard
- Use Lucide icon instead of PrimeIcons in SearchBoxV2
- Use function declaration instead of expression in NodeSearchBox
- Log pricing evaluation errors at debug level
- Extract preview breakpoint constant and use Tailwind overflow class
- Use reactive props destructuring in EssentialNodeCard
- Prevent one-frame preview position flash via opacity
- Cancel stale click-mode drag on listener cleanup
- Use explicit watch for hoverNode and fix category filter consistency
- Add dompurify to pnpm workspace catalog

### Test improvements (7)
- Add assertions to EssentialNodeCard drag and hover tests
- Use `vi.hoisted()` and `vi.resetAllMocks()` in useNodeDragToCanvas
tests
- Fix NodeSearchCategorySidebar test suite (describe reference, mock
ordering, test name)
- Move `vi.restoreAllMocks` before setup in NodeSearchFilterBar tests
- Add wrapper cleanup in TextTicker tests
- Add highlightQuery sanitization tests
- Reset NodeSearchBoxImpl setting in linkInteraction beforeEach

All 4,753 unit tests pass.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9001-Review-fixes-for-V2-Node-Search-8987-30d6d73d365081a892dac9bf533fb072)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-20 00:36:24 -08:00
GitHub Action
ab1b72a8df [automated] Apply ESLint and Oxfmt fixes 2026-02-19 17:31:08 +00:00
pythongosssss
615f5724f4 fix test 2026-02-19 09:26:18 -08:00
pythongosssss
67af4415a8 fix tests 2026-02-19 09:26:18 -08:00
pythongosssss
3d2699c07c fix tests & rabbit 2026-02-19 09:26:18 -08:00
pythongosssss
a597050d7c update tests to use v1 search 2026-02-19 09:26:18 -08:00
pythongosssss
c44155bcb3 hide node library setting 2026-02-19 09:26:18 -08:00
pythongosssss
99d6f87f00 set v2 search as default
update settings descriptions
hide essentials category if no essentials nodes
2026-02-19 09:26:18 -08:00
pythongosssss
c545c9d5d0 tweaks 2026-02-19 09:26:18 -08:00
pythongosssss
7d73f01588 Add text ticker 2026-02-19 09:26:18 -08:00
pythongosssss
663efeff35 Add text ticker overflow description 2026-02-19 09:26:18 -08:00
pythongosssss
f88a42619e add essentials 2026-02-19 09:26:18 -08:00
pythongosssss
ca86212d66 design feedback
- add separator to categories
- allow collapsing categories
- color tweaks
- swap input for tag component for keyboard nav
- extract node search input component
- fix toolbox selection after ghost
- tidy
2026-02-19 09:26:18 -08:00
pythongosssss
2e3a77567e review feedback 2026-02-19 09:25:24 -08:00
pythongosssss
d85db6367e new v2 node search box with categories - input/output/source filters - uses new preview, extracted price + provider badges - adds as ghost - tests 2026-02-19 09:25:12 -08:00
GitHub Action
c8f1e0395a [automated] Apply ESLint and Oxfmt fixes 2026-02-19 15:59:42 +00:00
Yourz
7d00323935 fix: unit tests 2026-02-19 23:54:48 +08:00
Yourz
cc85df9ae4 fix: design reviews 2026-02-19 23:41:48 +08:00
Yourz
def0def247 fix: design reviews 2026-02-19 23:40:05 +08:00
Yourz
4754be7567 fix: unit tests 2026-02-19 23:40:05 +08:00
Yourz
b52785a8a0 fix: update for design reviews 2026-02-19 23:40:05 +08:00
Yourz
1b16b0f6f8 fix: unit tests 2026-02-19 23:40:05 +08:00
Yourz
fedce61238 fix: update for design reviews, and add setting toggle to switch node library design 2026-02-19 23:40:05 +08:00
Yourz
726ae23cfd fix: add missing DEFAULT_SORTING_ID to test mock
Amp-Thread-ID: https://ampcode.com/threads/T-019c56f7-b162-760c-999d-024481032451
Co-authored-by: Amp <amp@ampcode.com>
2026-02-19 23:40:04 +08:00
Yourz
7a4f7dc4de fix: update for design reviews 2026-02-19 23:40:04 +08:00
Yourz
824f8bb281 fix: improve NodePreviewCard styling and drag-drop reliability
- Change NodePreviewCard width to w-50 with scale-50
- Add gap-2 between input/output name and type
- Truncate type text instead of name when overflowing
- Teleport preview to body to fix z-index stacking context issues
- Remove dropEffect check in handleDragEnd for consistent behavior
- Update unit tests to reflect new drag-drop behavior

Amp-Thread-ID: https://ampcode.com/threads/T-019c56f7-b162-760c-999d-024481032451
Co-authored-by: Amp <amp@ampcode.com>
2026-02-19 23:40:04 +08:00
github-actions
a4975a0e62 [automated] Update test expectations 2026-02-19 23:40:04 +08:00
Yourz
9a4aff542c fix: ci 2026-02-19 23:40:03 +08:00
Yourz
34dd961561 feat: essentials tab 2026-02-19 23:40:03 +08:00
Yourz
d78e4b92cb feat: support drag and drop creation 2026-02-19 23:40:03 +08:00
Yourz
41d4adaf45 fix: update icons 2026-02-19 23:40:03 +08:00
Yourz
99d31a7376 fix: update for reviews 2026-02-19 23:40:03 +08:00
Yourz
193d4827af fix: coderabbit reviews 2026-02-19 23:40:02 +08:00
Yourz
594c2497fd fix: coderabbit reviews 2026-02-19 23:40:02 +08:00
Yourz
1a94e34c0c fix: coderabbit reviews 2026-02-19 23:40:02 +08:00
Yourz
1aa10e7c8f fix: coderabbit reviews 2026-02-19 23:40:02 +08:00
Yourz
91ad183c24 feat: new drag preview 2026-02-19 23:40:02 +08:00
Yourz
e637471353 fix: ci and badge 2026-02-19 23:40:01 +08:00
Yourz
98532a8217 feat: api nodes icon 2026-02-19 23:40:01 +08:00
Yourz
70b514a48d feat: drop nodes when click the canvas 2026-02-19 23:40:00 +08:00
Yourz
f80b87b46c fix: reviews for coderabbit 2026-02-19 23:40:00 +08:00
Yourz
bde6678cd7 fix: reviews 2026-02-19 23:40:00 +08:00
Yourz
37477b2e43 fix: ci 2026-02-19 23:40:00 +08:00
Yourz
3f07dd255f fix: preview card, icon 2026-02-19 23:40:00 +08:00
Yourz
2ed895c3dc feat: previewCard and BadgPill and api node logo 2026-02-19 23:40:00 +08:00
Yourz
f5b0b70676 feat: add main_category field and group nodes by category
- Add main_category field to node definitions in api.ts
- Add main_category to ComfyNodeDefImpl class
- Group nodes by main_category in 'all' and 'custom' tabs
- Format category names from snake_case to Title Case

Amp-Thread-ID: https://ampcode.com/threads/T-019c1ee5-bb3c-70fb-8c24-33966d8dbef8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-19 23:40:00 +08:00
Yourz
9058d3ec54 feat: implement NodeLibrarySidebarTabV2 with Reka UI components
- Add three-tab structure (Essential, All, Custom) using Reka UI Tabs
- Implement TreeExplorerV2 with virtualized tree using TreeRoot/TreeVirtualizer
- Add node hover preview with teleport to show NodePreview component
- Implement context menu for toggling favorites on nodes
- Add search functionality that auto-expands matching folders
- Create panel components: EssentialNodesPanel, AllNodesPanel, CustomNodesPanel
- Add 'Open Manager' button in CustomNodesPanel
- Use custom icons: comfy--node for nodes, ph--folder-fill for folders

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c1ee5-bb3c-70fb-8c24-33966d8dbef8
2026-02-19 23:40:00 +08:00
111 changed files with 1600 additions and 2558 deletions

View File

@@ -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.'

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 () => {

View File

@@ -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'
}
})
})
})

View File

@@ -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'

View File

@@ -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;

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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)
})
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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()
})

View File

@@ -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()
})
})

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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)
})

View File

@@ -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()

View File

@@ -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"

View 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>

View File

@@ -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',

View File

@@ -17,7 +17,6 @@ vi.mock('@/scripts/app', () => ({
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn()
}))

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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'),

View File

@@ -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" />

View File

@@ -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)

View File

@@ -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'
})

View File

@@ -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)
}

View File

@@ -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
)

View File

@@ -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',

View File

@@ -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()
})
})

View File

@@ -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
}
}
}

View File

@@ -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: {

View File

@@ -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'

View File

@@ -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 = {}

View File

@@ -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',

View File

@@ -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']],
[

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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 wont 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"

View File

@@ -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

View File

@@ -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)
}
/**

View File

@@ -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'

View File

@@ -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[]> {

View File

@@ -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')
)
}

View File

@@ -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)
})
})
})

View File

@@ -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,

View File

@@ -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',

View File

@@ -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')
}

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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'
}

View File

@@ -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()
})

View File

@@ -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 }
}

View File

@@ -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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')

View File

@@ -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<{

View File

@@ -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)

View File

@@ -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"
/>

View File

@@ -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(

View File

@@ -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, {

View File

@@ -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(

View File

@@ -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

View File

@@ -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)
})

View File

@@ -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)
}

View File

@@ -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()
})

View File

@@ -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
)
}

View File

@@ -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)
}

View File

@@ -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 ??= {}

View File

@@ -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

View File

@@ -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

View File

@@ -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(),

View File

@@ -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())
})
})

View File

@@ -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
}
})

View File

@@ -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