mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
9 Commits
glary/mode
...
glary/fe-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc07b775a5 | ||
|
|
bf022a07e8 | ||
|
|
fafcc20c7d | ||
|
|
841990ec05 | ||
|
|
60c7471818 | ||
|
|
0ac4c3d6c5 | ||
|
|
feafdc0b4a | ||
|
|
2fea0aa538 | ||
|
|
a1ba567dbc |
@@ -7,7 +7,7 @@ export const mockSystemStats: SystemStatsResponse = {
|
||||
embedded_python: false,
|
||||
comfyui_version: '0.3.10',
|
||||
pytorch_version: '2.4.0+cu124',
|
||||
argv: ['main.py', '--listen', '0.0.0.0'],
|
||||
argv: ['main.py'],
|
||||
ram_total: 67108864000,
|
||||
ram_free: 52428800000
|
||||
},
|
||||
|
||||
@@ -167,7 +167,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => {
|
||||
...mockSystemStats,
|
||||
system: {
|
||||
...mockSystemStats.system,
|
||||
argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager']
|
||||
argv: ['main.py', '--enable-manager']
|
||||
}
|
||||
}
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.5",
|
||||
"version": "1.44.6",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -321,6 +321,39 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserves existing node callbacks through initializeLoad3d', () => {
|
||||
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
|
||||
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
|
||||
// unregisters the component widget from the DOM widget store. If
|
||||
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
|
||||
// cleanup is lost and the interactive UI persists with a stale reference.
|
||||
it('chains node.onRemoved with a preexisting callback', async () => {
|
||||
const existingOnRemoved = vi.fn()
|
||||
mockNode.onRemoved = existingOnRemoved
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('chains node.onResize with a preexisting callback', async () => {
|
||||
const existingOnResize = vi.fn()
|
||||
mockNode.onResize = existingOnResize
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockNode.onResize?.([512, 512] as Size)
|
||||
|
||||
expect(existingOnResize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForLoad3d', () => {
|
||||
it('should execute callback immediately if Load3d exists', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toRef } from '@vueuse/core'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import {
|
||||
@@ -133,30 +134,32 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
await restoreConfigurationsFromNode(node)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
|
||||
load3d?.refreshViewport()
|
||||
|
||||
load3d?.updateStatusMouseOnNode(true)
|
||||
}
|
||||
})
|
||||
|
||||
node.onMouseLeave = function () {
|
||||
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
|
||||
load3d?.updateStatusMouseOnNode(false)
|
||||
}
|
||||
})
|
||||
|
||||
node.onResize = function () {
|
||||
node.onResize = useChainCallback(node.onResize, () => {
|
||||
load3d?.handleResize()
|
||||
}
|
||||
})
|
||||
|
||||
node.onDrawBackground = function () {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
node.onDrawBackground = useChainCallback(
|
||||
node.onDrawBackground,
|
||||
function (this: LGraphNode) {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
node.onRemoved = function () {
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
useLoad3dService().removeLoad3d(node)
|
||||
pendingCallbacks.delete(node)
|
||||
}
|
||||
})
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
|
||||
@@ -323,6 +323,10 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
if (!(outputGroups?.[idx] == matchKey)) return
|
||||
changeOutputType(this, output, outputType)
|
||||
})
|
||||
// Force Vue reactivity update for output slot types.
|
||||
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
|
||||
// so mutating output.type alone doesn't trigger re-render.
|
||||
this.outputs = [...this.outputs]
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -384,7 +384,12 @@
|
||||
"legacyManagerSearchTip": "Looking for ComfyUI-Manager? You can enable the legacy manager UI by starting ComfyUI with the --enable-manager-legacy-ui flag.",
|
||||
"failed": "Failed",
|
||||
"failedToInstall": "Failed to Install",
|
||||
"failedTabIndicatorTooltip": "{count} installation failed | {count} installations failed",
|
||||
"installError": "Install Error",
|
||||
"installFailureToast": {
|
||||
"summary": "Installation failed",
|
||||
"detail": "{count} extension failed to install. Check the Failed tab for details. | {count} extensions failed to install. Check the Failed tab for details."
|
||||
},
|
||||
"importFailedGenericError": "Package failed to import. Check the console for more details.",
|
||||
"noNodesFound": "No nodes found",
|
||||
"noNodesFoundDescription": "The pack's nodes either could not be parsed, or the pack is a frontend extension only and doesn't have any nodes.",
|
||||
|
||||
@@ -1272,9 +1272,10 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
tooltip:
|
||||
'When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
experimental: true,
|
||||
versionAdded: '1.40.0'
|
||||
defaultValue: true,
|
||||
experimental: false,
|
||||
versionAdded: '1.40.0',
|
||||
versionModified: '1.44.5'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.DeduplicateSubgraphNodeIds',
|
||||
|
||||
@@ -18,11 +18,25 @@ const { isRestarting, isRestartCompleted, applyChanges } = useApplyChanges()
|
||||
const isExpanded = ref(false)
|
||||
const activeTabIndex = ref(0)
|
||||
|
||||
const FAILED_TAB_KEY = 'failed'
|
||||
|
||||
const failedCount = computed(() => comfyManagerStore.failedTasksIds.length)
|
||||
const hasFailures = computed(() => failedCount.value > 0)
|
||||
|
||||
const failedTabIndicatorLabel = computed(() =>
|
||||
t(
|
||||
'manager.failedTabIndicatorTooltip',
|
||||
{ count: failedCount.value },
|
||||
failedCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ label: t('manager.installationQueue') },
|
||||
{
|
||||
key: FAILED_TAB_KEY,
|
||||
label: t('manager.failed', {
|
||||
count: comfyManagerStore.failedTasksIds.length
|
||||
count: failedCount.value
|
||||
})
|
||||
}
|
||||
])
|
||||
@@ -169,7 +183,31 @@ onBeforeUnmount(() => {
|
||||
menuitem: { class: 'font-medium' },
|
||||
action: { class: 'px-4 py-2' }
|
||||
}"
|
||||
/>
|
||||
>
|
||||
<template #item="{ item, props, label }">
|
||||
<a
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2"
|
||||
:aria-label="
|
||||
item.key === FAILED_TAB_KEY && hasFailures
|
||||
? `${label} — ${failedTabIndicatorLabel}`
|
||||
: undefined
|
||||
"
|
||||
:title="
|
||||
item.key === FAILED_TAB_KEY && hasFailures
|
||||
? failedTabIndicatorLabel
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<i
|
||||
v-if="item.key === FAILED_TAB_KEY && hasFailures"
|
||||
class="pi pi-exclamation-circle text-danger"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</TabMenu>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||
@@ -55,6 +55,23 @@ vi.mock('vue-i18n', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const { toastAddMock } = vi.hoisted(() => ({
|
||||
toastAddMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({
|
||||
add: toastAddMock
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (params && 'count' in params) return `${key}:${String(params.count)}`
|
||||
return key
|
||||
}
|
||||
}))
|
||||
|
||||
interface EnabledDisabledTestCase {
|
||||
desc: string
|
||||
installed: Record<string, ManagerPackInstalled>
|
||||
@@ -79,6 +96,8 @@ describe('useComfyManagerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
toastAddMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
mockManagerService = {
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
@@ -102,6 +121,10 @@ describe('useComfyManagerStore', () => {
|
||||
vi.mocked(useComfyManagerService).mockReturnValue(mockManagerService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const testCases: EnabledDisabledTestCase[] = [
|
||||
{
|
||||
desc: 'Two enabled versions',
|
||||
@@ -501,4 +524,83 @@ describe('useComfyManagerStore', () => {
|
||||
expect(store.isPackInstalled('disabled-pack')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installation failure toast', () => {
|
||||
const setTaskHistory = (
|
||||
store: ReturnType<typeof useComfyManagerStore>,
|
||||
history: Record<string, ManagerComponents['schemas']['TaskHistoryItem']>
|
||||
) => {
|
||||
store.taskHistory = history
|
||||
}
|
||||
|
||||
const errorTask = (
|
||||
id: string
|
||||
): ManagerComponents['schemas']['TaskHistoryItem'] => ({
|
||||
ui_id: id,
|
||||
client_id: 'test',
|
||||
kind: 'install',
|
||||
result: 'failed',
|
||||
status: { status_str: 'error', completed: false, messages: ['boom'] },
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
const successTask = (
|
||||
id: string
|
||||
): ManagerComponents['schemas']['TaskHistoryItem'] => ({
|
||||
ui_id: id,
|
||||
client_id: 'test',
|
||||
kind: 'install',
|
||||
result: 'success',
|
||||
status: { status_str: 'success', completed: true, messages: [] },
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
it('shows an error toast when a task fails', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
setTaskHistory(store, { a: errorTask('a') })
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(toastAddMock).toHaveBeenCalledTimes(1)
|
||||
const message = toastAddMock.mock.calls[0][0]
|
||||
expect(message.severity).toBe('error')
|
||||
expect(message.summary).toBe('manager.installFailureToast.summary')
|
||||
expect(message.detail).toBe('manager.installFailureToast.detail:1')
|
||||
})
|
||||
|
||||
it('does not show a toast when all tasks succeed', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
setTaskHistory(store, { a: successTask('a'), b: successTask('b') })
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(toastAddMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('coalesces multiple failures that land in quick succession', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
setTaskHistory(store, { a: errorTask('a') })
|
||||
await nextTick()
|
||||
setTaskHistory(store, { a: errorTask('a'), b: errorTask('b') })
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(toastAddMock).toHaveBeenCalledTimes(1)
|
||||
expect(toastAddMock.mock.calls[0][0].detail).toBe(
|
||||
'manager.installFailureToast.detail:2'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show a stale toast when resetTaskState runs before the debounce fires', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
setTaskHistory(store, { a: errorTask('a') })
|
||||
await nextTick()
|
||||
|
||||
store.resetTaskState()
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(toastAddMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { useDebounceFn, useEventListener, whenever } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ref, watch } from 'vue'
|
||||
@@ -6,6 +6,7 @@ import { ref, watch } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useCachedRequest } from '@/composables/useCachedRequest'
|
||||
import { useServerLogs } from '@/composables/useServerLogs'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
@@ -112,6 +113,30 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const pendingFailureCount = ref(0)
|
||||
|
||||
const flushFailureToast = useDebounceFn(() => {
|
||||
if (pendingFailureCount.value === 0) return
|
||||
const count = pendingFailureCount.value
|
||||
pendingFailureCount.value = 0
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('manager.installFailureToast.summary'),
|
||||
detail: t('manager.installFailureToast.detail', { count }, count),
|
||||
life: 8000
|
||||
})
|
||||
}, 300)
|
||||
|
||||
watch(
|
||||
() => failedTasksIds.value.length,
|
||||
(newCount, oldCount) => {
|
||||
const delta = newCount - (oldCount ?? 0)
|
||||
if (delta <= 0) return
|
||||
pendingFailureCount.value += delta
|
||||
void flushFailureToast()
|
||||
}
|
||||
)
|
||||
|
||||
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
|
||||
|
||||
const isInstalledPackId = (packName: string | undefined): boolean =>
|
||||
@@ -336,7 +361,10 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
}
|
||||
|
||||
const resetTaskState = () => {
|
||||
// Clear all task-related reactive state for fresh start after restart
|
||||
// Clear all task-related reactive state for fresh start after restart.
|
||||
// Also clear pendingFailureCount so any in-flight debounced failure
|
||||
// toast (which reads this value before firing) becomes a no-op.
|
||||
pendingFailureCount.value = 0
|
||||
taskLogs.value = []
|
||||
taskHistory.value = {}
|
||||
succeededTasksIds.value = []
|
||||
|
||||
Reference in New Issue
Block a user