mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-11 14:50:41 +00:00
Compare commits
5 Commits
test/cover
...
cloud/1.43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9816951a39 | ||
|
|
e7c10aaf77 | ||
|
|
1ddc0bb125 | ||
|
|
fe8dc17d2d | ||
|
|
352f5a0cd4 |
@@ -64,6 +64,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
@@ -104,8 +105,7 @@
|
||||
"allowInterfaces": "always"
|
||||
}
|
||||
],
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
|
||||
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
|
||||
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
82
browser_tests/tests/cloud-asset-default.spec.ts
Normal file
82
browser_tests/tests/cloud-asset-default.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
|
||||
stubCloudAssets: [
|
||||
async ({ page }, use) => {
|
||||
const pattern = '**/api/assets?*'
|
||||
await page.route(pattern, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
)
|
||||
await use()
|
||||
await page.unroute(pattern)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('should use first cloud asset when server default is not in assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// The default workflow contains a CheckpointLoaderSimple node whose
|
||||
// server default (from object_info) is a local file not in cloud assets.
|
||||
// Wait for the existing node's asset widget to mount, confirming the
|
||||
// assets store has been populated from the stub before adding a new node.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
return node?.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)?.type
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('asset')
|
||||
|
||||
// Add a new CheckpointLoaderSimple — should use first cloud asset,
|
||||
// not the server's object_info default.
|
||||
const widgetValue = await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
|
||||
window.app!.graph.add(node!)
|
||||
const widget = node!.widgets?.find(
|
||||
(w: { name: string }) => w.name === 'ckpt_name'
|
||||
)
|
||||
return String(widget?.value ?? '')
|
||||
})
|
||||
|
||||
// Production resolves via getAssetFilename (user_metadata.filename →
|
||||
// metadata.filename → asset.name). Test fixtures have no metadata
|
||||
// filename, so asset.name is the resolved value.
|
||||
expect(widgetValue).toBe(CLOUD_ASSETS[0].name)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
const config: KnipConfig = {
|
||||
treatConfigHintsAsErrors: true,
|
||||
workspaces: {
|
||||
'.': {
|
||||
entry: [
|
||||
@@ -33,11 +34,9 @@ const config: KnipConfig = {
|
||||
'src/pages/**/*.astro',
|
||||
'src/layouts/**/*.astro',
|
||||
'src/components/**/*.vue',
|
||||
'src/styles/global.css',
|
||||
'astro.config.ts'
|
||||
'src/styles/global.css'
|
||||
],
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
|
||||
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
@@ -54,8 +53,6 @@ const config: KnipConfig = {
|
||||
// Auto generated API types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/ingest-types/src/zod.gen.ts',
|
||||
// Used by stacked PR (feat/glsl-live-preview)
|
||||
'src/renderer/glsl/useGLSLRenderer.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
|
||||
627
pnpm-lock.yaml
generated
627
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -74,7 +74,7 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.55.0
|
||||
eslint-plugin-oxlint: 1.59.0
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
@@ -89,14 +89,14 @@ catalog:
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^6.0.1
|
||||
knip: ^6.3.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.6.1
|
||||
oxfmt: ^0.40.0
|
||||
oxlint: ^1.55.0
|
||||
oxlint-tsgolint: ^0.17.0
|
||||
oxfmt: ^0.44.0
|
||||
oxlint: ^1.59.0
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
|
||||
@@ -32,21 +32,14 @@
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] text-xs" />
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
</button>
|
||||
<button
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +108,7 @@ const ROW_CLASS =
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
|
||||
|
||||
const ACTION_BTN_CLASS =
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-3.5" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -33,7 +33,7 @@
|
||||
:aria-label="$t('g.edit')"
|
||||
@click.stop="editBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen] size-3.5" />
|
||||
<i class="icon-[lucide--square-pen] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else #actions>
|
||||
|
||||
@@ -123,7 +123,8 @@ export const useContextMenuTranslation = () => {
|
||||
}
|
||||
|
||||
// for capture translation text of input and widget
|
||||
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
|
||||
const extraInfo = (options.extra ||
|
||||
options.parentMenu?.options?.extra) as
|
||||
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
|
||||
| undefined
|
||||
// widgets and inputs
|
||||
|
||||
138
src/composables/useReconnectingNotification.test.ts
Normal file
138
src/composables/useReconnectingNotification.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
const mockToastRemove = vi.fn()
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd,
|
||||
remove: mockToastRemove
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
disableToast: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Toast.DisableReconnectingToast')
|
||||
return settingMocks.disableToast
|
||||
return undefined
|
||||
})
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useReconnectingNotification', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
settingMocks.disableToast = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not show toast immediately on reconnecting', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error toast after delay', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses toast when reconnected before delay expires', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(500)
|
||||
onReconnected()
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes toast and shows success when reconnected after delay', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
mockToastAdd.mockClear()
|
||||
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
})
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'g.reconnected',
|
||||
life: 2000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when toast is disabled via setting', () => {
|
||||
settingMocks.disableToast = true
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when onReconnected is called without prior onReconnecting', () => {
|
||||
const { onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles multiple reconnecting events without duplicating toasts', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500) // first toast fires
|
||||
onReconnecting() // second reconnecting event
|
||||
vi.advanceTimersByTime(1500) // second toast fires
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
52
src/composables/useReconnectingNotification.ts
Normal file
52
src/composables/useReconnectingNotification.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const RECONNECT_TOAST_DELAY_MS = 1500
|
||||
|
||||
export function useReconnectingNotification() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('g.reconnecting')
|
||||
}
|
||||
|
||||
const reconnectingToastShown = ref(false)
|
||||
|
||||
const { start, stop } = useTimeoutFn(
|
||||
() => {
|
||||
toast.add(reconnectingMessage)
|
||||
reconnectingToastShown.value = true
|
||||
},
|
||||
RECONNECT_TOAST_DELAY_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function onReconnecting() {
|
||||
if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return
|
||||
start()
|
||||
}
|
||||
|
||||
function onReconnected() {
|
||||
stop()
|
||||
|
||||
if (reconnectingToastShown.value) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
reconnectingToastShown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { onReconnecting, onReconnected }
|
||||
}
|
||||
@@ -115,7 +115,7 @@ describe('resolvePromotedWidgetAtHost', () => {
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(
|
||||
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,9 +143,10 @@ app.registerExtension({
|
||||
throw new Error(err)
|
||||
}
|
||||
const data = await resp.json()
|
||||
const serverName = data.name ?? name
|
||||
const subfolder = data.subfolder ?? 'webcam'
|
||||
return `${subfolder}/${serverName} [temp]`
|
||||
const serverName = data.name || name
|
||||
const subfolder = data.subfolder || 'webcam'
|
||||
const type = data.type || 'temp'
|
||||
return `${subfolder}/${serverName} [${type}]`
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('GtmTelemetryProvider', () => {
|
||||
event: 'execution_error',
|
||||
node_type: 'KSampler'
|
||||
})
|
||||
expect((entry?.error as string).length).toBe(100)
|
||||
expect(entry!.error as string).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('pushes select_content for template events', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
|
||||
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
|
||||
const mockAssetsStoreState = vi.hoisted(() => {
|
||||
const inputAssets: AssetItem[] = []
|
||||
return {
|
||||
@@ -55,7 +56,8 @@ vi.mock('@/stores/assetsStore', () => ({
|
||||
return mockAssetsStoreState.inputLoading
|
||||
},
|
||||
updateInputs: mockUpdateInputs,
|
||||
getInputName: mockGetInputName
|
||||
getInputName: mockGetInputName,
|
||||
getAssets: mockGetAssets
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -199,67 +201,117 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
describe('cloud asset browser widget', () => {
|
||||
// "Select model" is the fallback from t('widgets.selectModel')
|
||||
// in createAssetWidget when defaultValue is undefined.
|
||||
const PLACEHOLDER = 'Select model'
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'model1.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
function setupCloudAssetWidget(
|
||||
inputSpecOverrides: Partial<InputSpec> = {}
|
||||
) {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: ''
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
...inputSpecOverrides
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
return { mockNode }
|
||||
}
|
||||
|
||||
function getWidgetDefault(mockNode: ReturnType<typeof createMockNode>) {
|
||||
return vi.mocked(mockNode.addWidget).mock.calls[0]?.[2]
|
||||
}
|
||||
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
expect(
|
||||
vi.mocked(assetService.shouldUseAssetBrowser)
|
||||
).toHaveBeenCalledWith('CheckpointLoaderSimple', 'ckpt_name')
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
expect.anything(),
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
it('should use first cloud asset as default instead of server combo options', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'model1.safetensors',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(vi.mocked(assetService.shouldUseAssetBrowser)).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name'
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['local_only_model.safetensors']
|
||||
})
|
||||
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'fallback.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
default: 'fallback.safetensors'
|
||||
// Note: no options array provided
|
||||
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'cloud_model.safetensors' })
|
||||
])
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'fallback.safetensors',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
default: 'not_in_cloud.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
|
||||
})
|
||||
|
||||
it('should prefer inputSpec.default when it exists in cloud assets', () => {
|
||||
mockGetAssets.mockReturnValue([
|
||||
createMockAssetItem({ name: 'other_model.safetensors' }),
|
||||
createMockAssetItem({ name: 'fallback.safetensors' })
|
||||
])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
// Note: no options array provided
|
||||
default: 'fallback.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe('fallback.safetensors')
|
||||
})
|
||||
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockGetAssets.mockReturnValue([])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
// Note: no options array provided
|
||||
default: 'fallback.safetensors'
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('should fallback to placeholder when cloud assets not loaded', () => {
|
||||
mockGetAssets.mockReturnValue([])
|
||||
|
||||
const { mockNode } = setupCloudAssetWidget({
|
||||
options: ['local_model.safetensors']
|
||||
})
|
||||
|
||||
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show Select model when asset widget has undefined current value', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type {
|
||||
@@ -104,6 +105,25 @@ const addMultiSelectWidget = (
|
||||
return widget
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default value for a cloud asset widget.
|
||||
* Priority: inputSpec.default (if present in cloud assets) → first cloud
|
||||
* asset → undefined (shows placeholder).
|
||||
*/
|
||||
function resolveCloudDefault(
|
||||
nodeType: string,
|
||||
specDefault: string | undefined
|
||||
): string | undefined {
|
||||
const assets = useAssetsStore().getAssets(nodeType)
|
||||
if (specDefault != null) {
|
||||
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
|
||||
if (inAssets) return specDefault
|
||||
}
|
||||
// empty filename → undefined (shows placeholder)
|
||||
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
|
||||
return filename || undefined
|
||||
}
|
||||
|
||||
function createAssetBrowserWidget(
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec,
|
||||
@@ -195,7 +215,14 @@ const addComboWidget = (
|
||||
|
||||
if (isCloud) {
|
||||
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
|
||||
return createAssetBrowserWidget(node, inputSpec, defaultValue)
|
||||
// Default from cloud assets, not from server combo options.
|
||||
// Server options list local files that may not exist in the user's
|
||||
// cloud asset library, leading to missing-model errors on undo/reload.
|
||||
const cloudDefault = resolveCloudDefault(
|
||||
node.comfyClass ?? '',
|
||||
inputSpec.default
|
||||
)
|
||||
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
|
||||
}
|
||||
|
||||
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
|
||||
|
||||
@@ -17,12 +17,12 @@ interface AutogrowGroup {
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
export interface UniformSource {
|
||||
interface UniformSource {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
export interface UniformSources {
|
||||
interface UniformSources {
|
||||
floats: UniformSource[]
|
||||
ints: UniformSource[]
|
||||
bools: UniformSource[]
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { runWhenGlobalIdle } from '@/base/common/async'
|
||||
import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
@@ -58,6 +56,7 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
|
||||
@@ -103,8 +102,6 @@ setupAutoQueueHandler()
|
||||
useProgressFavicon()
|
||||
useBrowserTabTitle()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -250,28 +247,7 @@ const onExecutionSuccess = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('g.reconnecting')
|
||||
}
|
||||
|
||||
const onReconnecting = () => {
|
||||
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add(reconnectingMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const onReconnected = () => {
|
||||
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
useEventListener(api, 'status', onStatus)
|
||||
useEventListener(api, 'execution_success', onExecutionSuccess)
|
||||
|
||||
Reference in New Issue
Block a user