mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-20 12:27:36 +00:00
Compare commits
12 Commits
cloud/1.40
...
remove-usa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e0890aa3a | ||
|
|
f9317e7078 | ||
|
|
79e71a5761 | ||
|
|
3e4d273832 | ||
|
|
8aa4e36fd5 | ||
|
|
d9fdb01d9b | ||
|
|
8f48b11f6a | ||
|
|
bb40ffae3c | ||
|
|
de131133bd | ||
|
|
17f34788dc | ||
|
|
9184f9bce4 | ||
|
|
ea7bbb744f |
@@ -90,7 +90,6 @@ const preview: Preview = {
|
||||
{ value: 'light', icon: 'sun', title: 'Light' },
|
||||
{ value: 'dark', icon: 'moon', title: 'Dark' }
|
||||
],
|
||||
showName: true,
|
||||
dynamicTitle: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,9 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
|
||||
// Monitor for server feature flags
|
||||
const checkInterval = setInterval(() => {
|
||||
if (
|
||||
window.app?.api?.serverFeatureFlags &&
|
||||
Object.keys(window.app.api.serverFeatureFlags).length > 0
|
||||
) {
|
||||
window.__capturedMessages!.serverFeatureFlags =
|
||||
window.app.api.serverFeatureFlags
|
||||
const flags = window.app?.api?.serverFeatureFlags?.value
|
||||
if (flags && Object.keys(flags).length > 0) {
|
||||
window.__capturedMessages!.serverFeatureFlags = flags
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
}, 100)
|
||||
@@ -96,7 +93,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
}) => {
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverFeatureFlags
|
||||
return window.app!.api.serverFeatureFlags.value
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
@@ -129,8 +126,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Test that the method only returns true for boolean true values
|
||||
const testResults = await comfyPage.page.evaluate(() => {
|
||||
// Temporarily modify serverFeatureFlags to test behavior
|
||||
const original = window.app!.api.serverFeatureFlags
|
||||
window.app!.api.serverFeatureFlags = {
|
||||
const original = window.app!.api.serverFeatureFlags.value
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
bool_true: true,
|
||||
bool_false: false,
|
||||
string_value: 'yes',
|
||||
@@ -147,7 +144,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
}
|
||||
|
||||
// Restore original
|
||||
window.app!.api.serverFeatureFlags = original
|
||||
window.app!.api.serverFeatureFlags.value = original
|
||||
return results
|
||||
})
|
||||
|
||||
@@ -282,8 +279,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Monitor when feature flags arrive by checking periodically
|
||||
const checkFeatureFlags = setInterval(() => {
|
||||
if (
|
||||
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined
|
||||
window.app?.api?.serverFeatureFlags?.value
|
||||
?.supports_preview_metadata !== undefined
|
||||
) {
|
||||
window.__appReadiness!.featureFlagsReceived = true
|
||||
clearInterval(checkFeatureFlags)
|
||||
@@ -320,8 +317,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Wait for feature flags to be received
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined,
|
||||
window.app?.api?.serverFeatureFlags?.value
|
||||
?.supports_preview_metadata !== undefined,
|
||||
{
|
||||
timeout: 10000
|
||||
}
|
||||
@@ -331,7 +328,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...window.__appReadiness,
|
||||
currentFlags: window.app!.api.serverFeatureFlags
|
||||
currentFlags: window.app!.api.serverFeatureFlags.value
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
async function loadImageOnNode(
|
||||
comfyPage: Awaited<
|
||||
ReturnType<(typeof test)['info']>
|
||||
>['fixtures']['comfyPage']
|
||||
) {
|
||||
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragAndDropFile('image64x64.webp', {
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
@@ -29,6 +28,7 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
return imagePreview
|
||||
}
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('opens mask editor from image preview button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -40,6 +40,7 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('shows image context menu options', async ({ comfyPage }) => {
|
||||
await loadImageOnNode(comfyPage)
|
||||
|
||||
|
||||
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.40.10",
|
||||
"version": "1.41.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -70,13 +70,14 @@
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tiptap/core": "^2.10.4",
|
||||
"@tiptap/extension-link": "^2.10.4",
|
||||
"@tiptap/extension-table": "^2.10.4",
|
||||
"@tiptap/extension-table-cell": "^2.10.4",
|
||||
"@tiptap/extension-table-header": "^2.10.4",
|
||||
"@tiptap/extension-table-row": "^2.10.4",
|
||||
"@tiptap/starter-kit": "^2.10.4",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
"@tiptap/extension-table": "catalog:",
|
||||
"@tiptap/extension-table-cell": "catalog:",
|
||||
"@tiptap/extension-table-header": "catalog:",
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -93,9 +94,9 @@
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
"glob": "catalog:",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"jsondiffpatch": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "catalog:",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getMediaTypeFromFilename,
|
||||
highlightQuery,
|
||||
isPreviewableMediaType,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -56,7 +57,8 @@ describe('formatUtil', () => {
|
||||
{ filename: 'image.jpeg', expected: 'image' },
|
||||
{ filename: 'animation.gif', expected: 'image' },
|
||||
{ filename: 'web.webp', expected: 'image' },
|
||||
{ filename: 'bitmap.bmp', expected: 'image' }
|
||||
{ filename: 'bitmap.bmp', expected: 'image' },
|
||||
{ filename: 'modern.avif', expected: 'image' }
|
||||
]
|
||||
|
||||
it.for(imageTestCases)(
|
||||
@@ -96,26 +98,37 @@ describe('formatUtil', () => {
|
||||
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text files', () => {
|
||||
it('should identify text file extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('notes.txt')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('readme.md')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('data.json')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('table.csv')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('config.yaml')).toBe('text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty strings', () => {
|
||||
expect(getMediaTypeFromFilename('')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('')).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle files without extensions', () => {
|
||||
expect(getMediaTypeFromFilename('README')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('README')).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle unknown extensions', () => {
|
||||
expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('data.json')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('document.pdf')).toBe('other')
|
||||
expect(getMediaTypeFromFilename('archive.bin')).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle files with multiple dots', () => {
|
||||
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle paths with directories', () => {
|
||||
@@ -124,8 +137,8 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
it('should handle null and undefined gracefully', () => {
|
||||
expect(getMediaTypeFromFilename(null)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(undefined)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(null)).toBe('other')
|
||||
expect(getMediaTypeFromFilename(undefined)).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle special characters in filenames', () => {
|
||||
@@ -184,4 +197,18 @@ describe('formatUtil', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,19 +494,41 @@ export function formatDuration(milliseconds: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
|
||||
const IMAGE_EXTENSIONS = [
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'avif',
|
||||
'tif',
|
||||
'tiff'
|
||||
] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'json',
|
||||
'csv',
|
||||
'yaml',
|
||||
'yml',
|
||||
'xml',
|
||||
'log'
|
||||
] as const
|
||||
|
||||
const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
|
||||
type MediaType = (typeof MEDIA_TYPES)[number]
|
||||
const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const
|
||||
export type MediaType = (typeof MEDIA_TYPES)[number]
|
||||
|
||||
// Type guard helper for checking array membership
|
||||
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
||||
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
||||
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
|
||||
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
|
||||
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
|
||||
|
||||
/**
|
||||
* Truncates a filename while preserving the extension
|
||||
@@ -543,20 +565,30 @@ export function truncateFilename(
|
||||
/**
|
||||
* Determines the media type from a filename's extension (singular form)
|
||||
* @param filename The filename to analyze
|
||||
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
||||
* @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other'
|
||||
*/
|
||||
export function getMediaTypeFromFilename(
|
||||
filename: string | null | undefined
|
||||
): MediaType {
|
||||
if (!filename) return 'image'
|
||||
if (!filename) return 'other'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'image'
|
||||
if (!ext) return 'other'
|
||||
|
||||
// Type-safe array includes check using type assertion
|
||||
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
|
||||
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
|
||||
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
|
||||
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
|
||||
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
|
||||
|
||||
return 'image'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
}
|
||||
|
||||
5063
pnpm-lock.yaml
generated
5063
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,12 @@ catalog:
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@nx/eslint': 22.2.6
|
||||
'@nx/playwright': 22.2.6
|
||||
'@nx/storybook': 22.2.4
|
||||
'@nx/vite': 22.2.6
|
||||
'@nx/eslint': 22.5.2
|
||||
'@nx/playwright': 22.5.2
|
||||
'@nx/storybook': 22.5.2
|
||||
'@nx/vite': 22.5.2
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -27,11 +27,19 @@ catalog:
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tiptap/core': ^2.27.2
|
||||
'@tiptap/extension-link': ^2.27.2
|
||||
'@tiptap/extension-table': ^2.27.2
|
||||
'@tiptap/extension-table-cell': ^2.27.2
|
||||
'@tiptap/extension-table-header': ^2.27.2
|
||||
'@tiptap/extension-table-row': ^2.27.2
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@tiptap/starter-kit': ^2.27.2
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
@@ -45,7 +53,7 @@ catalog:
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
axios: ^1.8.2
|
||||
axios: ^1.13.5
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: ^3.3.1
|
||||
@@ -55,24 +63,26 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.25.0
|
||||
eslint-plugin-storybook: ^10.1.9
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
globals: ^16.5.0
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.2.6
|
||||
oxfmt: ^0.26.0
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
nx: 22.5.2
|
||||
oxfmt: ^0.34.0
|
||||
oxlint: ^1.49.0
|
||||
oxlint-tsgolint: ^0.14.2
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
@@ -81,9 +91,9 @@ catalog:
|
||||
primevue: ^4.2.5
|
||||
reka-ui: ^2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.1.9
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.1.12
|
||||
tailwindcss: ^4.2.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
@@ -100,10 +110,10 @@ catalog:
|
||||
vitest: ^4.0.16
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.2.0
|
||||
vue-i18n: ^9.14.3
|
||||
vue-eslint-parser: ^10.4.0
|
||||
vue-i18n: ^9.14.5
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.2.1
|
||||
vue-tsc: ^3.2.5
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yjs: ^13.6.27
|
||||
@@ -130,4 +140,5 @@ onlyBuiltDependencies:
|
||||
- oxc-resolver
|
||||
|
||||
overrides:
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
|
||||
@@ -169,6 +169,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -189,6 +190,7 @@ const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
@@ -262,7 +264,7 @@ const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
const { hasAnyError } = storeToRefs(executionErrorStore)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.decrement')"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
@@ -51,7 +51,7 @@
|
||||
<slot />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.increment')"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
|
||||
@@ -729,10 +729,11 @@ const sortOptions = computed(() => [
|
||||
value: 'popular'
|
||||
},
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
value: 'vram-low-to-high'
|
||||
},
|
||||
// TODO: Uncomment when we have a way to get the real VRAM usage
|
||||
// {
|
||||
// name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
// value: 'vram-low-to-high'
|
||||
// },
|
||||
{
|
||||
name: t(
|
||||
'templateWorkflows.sort.modelSizeLowToHigh',
|
||||
|
||||
@@ -64,17 +64,17 @@ import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
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 executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
@@ -90,7 +90,7 @@ const isVisible = computed(
|
||||
)
|
||||
|
||||
function dismiss() {
|
||||
executionStore.dismissErrorOverlay()
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
@@ -100,6 +100,6 @@ function seeErrors() {
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionStore.dismissErrorOverlay()
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
executionErrorStore.lastExecutionError?.node_id === nodeData.id
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
@@ -170,6 +170,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
@@ -196,6 +197,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const toastStore = useToastStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
@@ -376,7 +378,7 @@ watch(
|
||||
// Update node slot errors for LiteGraph nodes
|
||||
// (Vue nodes read from store directly)
|
||||
watch(
|
||||
() => executionStore.lastNodeErrors,
|
||||
() => executionErrorStore.lastNodeErrors,
|
||||
(lastNodeErrors) => {
|
||||
if (!comfyApp.graph) return
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -36,12 +36,12 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
import TabErrors from './errors/TabErrors.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -98,7 +98,7 @@ type RightSidePanelTabList = Array<{
|
||||
|
||||
const hasDirectNodeError = computed(() =>
|
||||
selectedNodes.value.some((node) =>
|
||||
executionStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||
executionErrorStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -106,7 +106,7 @@ 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)
|
||||
return executionErrorStore.hasInternalErrorForNode(node.id)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
it('renders prompt-level errors (Group title = error message)', async () => {
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastPromptError: {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'Server Error: No outputs',
|
||||
@@ -118,7 +118,7 @@ describe('TabErrors.vue', () => {
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'6': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
@@ -143,7 +143,7 @@ describe('TabErrors.vue', () => {
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '10',
|
||||
@@ -167,7 +167,7 @@ describe('TabErrors.vue', () => {
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
@@ -198,7 +198,7 @@ describe('TabErrors.vue', () => {
|
||||
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
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
|
||||
@@ -192,7 +193,7 @@ export function useErrorGroups(
|
||||
searchQuery: Ref<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
@@ -223,7 +224,7 @@ export function useErrorGroups(
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
for (const execId of executionStore.allErrorExecutionIds) {
|
||||
for (const execId of executionErrorStore.allErrorExecutionIds) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (node) map.set(execId, node)
|
||||
}
|
||||
@@ -262,10 +263,10 @@ export function useErrorGroups(
|
||||
}
|
||||
|
||||
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
||||
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
|
||||
if (selectedNodeInfo.value.nodeIds || !executionErrorStore.lastPromptError)
|
||||
return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const error = executionErrorStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
@@ -293,10 +294,10 @@ export function useErrorGroups(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
@@ -316,9 +317,9 @@ export function useErrorGroups(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { 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 { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -62,7 +62,7 @@ watchEffect(() => (widgets.value = widgetsProp))
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { t } = useI18n()
|
||||
@@ -110,7 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
|
||||
|
||||
const hasDirectError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
return executionErrorStore.activeGraphErrorNodeIds.has(
|
||||
String(targetNode.value.id)
|
||||
)
|
||||
})
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
@@ -119,7 +121,7 @@ const hasContainerInternalError = computed(() => {
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
|
||||
return executionStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
return executionErrorStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</slot>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
t(label)
|
||||
st(label, label)
|
||||
}}</span>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -50,12 +50,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
icon = '',
|
||||
selected = false,
|
||||
@@ -83,7 +81,7 @@ const overlayValue = computed(() =>
|
||||
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
|
||||
)
|
||||
const shouldShowBadge = computed(() => !!overlayValue.value)
|
||||
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
|
||||
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -112,6 +112,22 @@ const sampleAssets: AssetItem[] = [
|
||||
created_at: baseTimestamp,
|
||||
size: 134217728,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'asset-text-1',
|
||||
name: 'generation-notes.txt',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 2048,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'asset-other-1',
|
||||
name: 'workflow-payload.bin',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 4096,
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -134,6 +150,16 @@ export const RunningAndGenerated: Story = {
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
export const TextAndMiscGeneratedAssets: Story = {
|
||||
args: {
|
||||
assets: sampleAssets.filter((asset) =>
|
||||
['.txt', '.bin'].some((suffix) => asset.name.endsWith(suffix))
|
||||
),
|
||||
jobs: []
|
||||
},
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
function renderAssetsSidebarListView(args: StoryArgs) {
|
||||
return {
|
||||
components: { AssetsSidebarListView },
|
||||
|
||||
@@ -89,4 +89,21 @@ describe('AssetsSidebarListView', () => {
|
||||
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
|
||||
expect(assetListItem?.props('isVideoPreview')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses icon fallback for text assets even when preview_url exists', () => {
|
||||
const textAsset = {
|
||||
...buildAsset('text-asset', 'notes.txt'),
|
||||
preview_url: '/api/view/notes.txt',
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountListView([buildOutputItem(textAsset)])
|
||||
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
const assetListItem = listItems.at(-1)
|
||||
|
||||
expect(assetListItem).toBeDefined()
|
||||
expect(assetListItem?.props('previewUrl')).toBe('')
|
||||
expect(assetListItem?.props('isVideoPreview')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="item.asset.preview_url"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
@@ -142,6 +142,14 @@ function isVideoAsset(asset: AssetItem): boolean {
|
||||
return getAssetMediaType(asset) === 'video'
|
||||
}
|
||||
|
||||
function getAssetPreviewUrl(asset: AssetItem): string {
|
||||
const mediaType = getAssetMediaType(asset)
|
||||
if (mediaType === 'image' || mediaType === 'video') {
|
||||
return asset.preview_url || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getAssetSecondaryText(asset: AssetItem): string {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
||||
|
||||
@@ -204,13 +204,22 @@ import {
|
||||
} from '@vueuse/core'
|
||||
import Divider from 'primevue/divider'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
const Load3dViewerContent = () =>
|
||||
import('@/components/load3d/Load3dViewerContent.vue')
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
)
|
||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
@@ -235,7 +244,11 @@ import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import {
|
||||
formatDuration,
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -396,6 +409,12 @@ const visibleAssets = computed(() => {
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
const previewableVisibleAssets = computed(() =>
|
||||
visibleAssets.value.filter((asset) =>
|
||||
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
|
||||
)
|
||||
)
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const isBulkMode = computed(
|
||||
@@ -421,12 +440,10 @@ watch(visibleAssets, (newAssets) => {
|
||||
// so selection stays consistent with what this view can act on.
|
||||
reconcileSelection(newAssets)
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
const newIndex = previewableVisibleAssets.value.findIndex(
|
||||
(asset) => asset.id === currentGalleryAssetId.value
|
||||
)
|
||||
if (newIndex !== -1) {
|
||||
galleryActiveIndex.value = newIndex
|
||||
}
|
||||
galleryActiveIndex.value = newIndex
|
||||
}
|
||||
})
|
||||
|
||||
@@ -437,7 +454,7 @@ watch(galleryActiveIndex, (index) => {
|
||||
})
|
||||
|
||||
const galleryItems = computed(() => {
|
||||
return visibleAssets.value.map((asset) => {
|
||||
return previewableVisibleAssets.value.map((asset) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
@@ -543,6 +560,9 @@ const handleDeleteSelected = async () => {
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
if (!isPreviewableMediaType(mediaType)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mediaType === '3D') {
|
||||
const dialogStore = useDialogStore()
|
||||
@@ -562,7 +582,9 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
currentGalleryAssetId.value = asset.id
|
||||
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
|
||||
const index = previewableVisibleAssets.value.findIndex(
|
||||
(a) => a.id === asset.id
|
||||
)
|
||||
if (index !== -1) {
|
||||
galleryActiveIndex.value = index
|
||||
}
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "تسجيل الخروج",
|
||||
"success": "تم تسجيل الخروج بنجاح",
|
||||
"successDetail": "لقد تم تسجيل خروجك من حسابك."
|
||||
"successDetail": "لقد تم تسجيل خروجك من حسابك.",
|
||||
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد عند تسجيل الخروج. هل ترغب في المتابعة؟",
|
||||
"unsavedChangesTitle": "تغييرات غير محفوظة"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "هل لديك حساب بالفعل؟",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Cerrar sesión",
|
||||
"success": "Sesión cerrada correctamente",
|
||||
"successDetail": "Has cerrado sesión en tu cuenta."
|
||||
"successDetail": "Has cerrado sesión en tu cuenta.",
|
||||
"unsavedChangesMessage": "Tienes cambios no guardados que se perderán al cerrar sesión. ¿Quieres continuar?",
|
||||
"unsavedChangesTitle": "Cambios no guardados"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "خروج",
|
||||
"success": "خروج با موفقیت انجام شد",
|
||||
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید."
|
||||
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید.",
|
||||
"unsavedChangesMessage": "شما تغییرات ذخیرهنشدهای دارید که با خروج از حساب از بین خواهند رفت. آیا مایل به ادامه هستید؟",
|
||||
"unsavedChangesTitle": "تغییرات ذخیرهنشده"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "قبلاً حساب کاربری دارید؟",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Se déconnecter",
|
||||
"success": "Déconnexion réussie",
|
||||
"successDetail": "Vous avez été déconnecté de votre compte."
|
||||
"successDetail": "Vous avez été déconnecté de votre compte.",
|
||||
"unsavedChangesMessage": "Vous avez des modifications non enregistrées qui seront perdues si vous vous déconnectez. Voulez-vous continuer ?",
|
||||
"unsavedChangesTitle": "Modifications non enregistrées"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Vous avez déjà un compte?",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "ログアウト",
|
||||
"success": "正常にサインアウトしました",
|
||||
"successDetail": "アカウントからサインアウトしました。"
|
||||
"successDetail": "アカウントからサインアウトしました。",
|
||||
"unsavedChangesMessage": "サインアウトすると未保存の変更が失われます。続行しますか?",
|
||||
"unsavedChangesTitle": "未保存の変更"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "로그아웃",
|
||||
"success": "성공적으로 로그아웃되었습니다",
|
||||
"successDetail": "계정에서 로그아웃되었습니다."
|
||||
"successDetail": "계정에서 로그아웃되었습니다.",
|
||||
"unsavedChangesMessage": "저장되지 않은 변경 사항이 있습니다. 로그아웃하면 변경 사항이 사라집니다. 계속하시겠습니까?",
|
||||
"unsavedChangesTitle": "저장되지 않은 변경 사항"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "이미 계정이 있으신가요?",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Sair",
|
||||
"success": "Logout realizado com sucesso",
|
||||
"successDetail": "Você saiu da sua conta."
|
||||
"successDetail": "Você saiu da sua conta.",
|
||||
"unsavedChangesMessage": "Você tem alterações não salvas que serão perdidas ao sair. Deseja continuar?",
|
||||
"unsavedChangesTitle": "Alterações não salvas"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Já tem uma conta?",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Выйти",
|
||||
"success": "Вы успешно вышли из системы",
|
||||
"successDetail": "Вы вышли из своей учетной записи."
|
||||
"successDetail": "Вы вышли из своей учетной записи.",
|
||||
"unsavedChangesMessage": "У вас есть несохранённые изменения, которые будут потеряны при выходе из системы. Вы хотите продолжить?",
|
||||
"unsavedChangesTitle": "Несохранённые изменения"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Уже есть аккаунт?",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Çıkış Yap",
|
||||
"success": "Başarıyla çıkış yapıldı",
|
||||
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız."
|
||||
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız.",
|
||||
"unsavedChangesMessage": "Oturumu kapattığınızda kaydedilmemiş değişiklikleriniz kaybolacak. Devam etmek istiyor musunuz?",
|
||||
"unsavedChangesTitle": "Kaydedilmemiş Değişiklikler"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Zaten bir hesabınız var mı?",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "登出",
|
||||
"success": "成功登出",
|
||||
"successDetail": "您已成功登出您的帳戶。"
|
||||
"successDetail": "您已成功登出您的帳戶。",
|
||||
"unsavedChangesMessage": "您有尚未儲存的變更,登出後這些變更將會遺失。您確定要繼續嗎?",
|
||||
"unsavedChangesTitle": "未儲存的變更"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "已經有帳戶?",
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "退出登录",
|
||||
"success": "成功退出登录",
|
||||
"successDetail": "您已成功退出账户。"
|
||||
"successDetail": "您已成功退出账户。",
|
||||
"unsavedChangesMessage": "您有未保存的更改,注销后这些更改将会丢失。是否继续?",
|
||||
"unsavedChangesTitle": "未保存的更改"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "已经有账户了?",
|
||||
|
||||
@@ -141,6 +141,40 @@ export const AudioAsset: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const TextAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-5',
|
||||
name: 'generation-notes.txt',
|
||||
size: 2048,
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OtherAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-6',
|
||||
name: 'workflow-payload.bin',
|
||||
size: 8192,
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoadingState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<component
|
||||
:is="getTopComponent(fileKind)"
|
||||
:is="getTopComponent(previewKind)"
|
||||
v-else-if="asset && adaptedAsset"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
@@ -59,6 +59,7 @@
|
||||
>
|
||||
<IconGroup background-class="bg-white">
|
||||
<Button
|
||||
v-if="canInspect"
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.zoom')"
|
||||
@@ -141,7 +142,8 @@ import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -152,17 +154,21 @@ import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue')),
|
||||
text: defineAsyncComponent(() => import('./MediaTextTop.vue')),
|
||||
other: defineAsyncComponent(() => import('./MediaOtherTop.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
function getTopComponent(kind: PreviewKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.other
|
||||
}
|
||||
|
||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
@@ -206,9 +212,15 @@ const assetType = computed(() => {
|
||||
|
||||
// Determine file type from extension
|
||||
const fileKind = computed((): MediaKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
})
|
||||
|
||||
const previewKind = computed((): PreviewKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
})
|
||||
|
||||
const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
|
||||
|
||||
// Get filename without extension
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset?.name || '').filename
|
||||
@@ -270,7 +282,7 @@ const showActionsOverlay = computed(() => {
|
||||
})
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
if (asset && canInspect.value) {
|
||||
emit('zoom', asset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { isPreviewableMediaType } from '@/utils/formatUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -206,8 +207,8 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
|
||||
// Individual mode: Show all menu options
|
||||
|
||||
// Inspect (if not 3D)
|
||||
if (fileKind !== '3D') {
|
||||
// Inspect
|
||||
if (isPreviewableMediaType(fileKind)) {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.inspect'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
|
||||
9
src/platform/assets/components/MediaOtherTop.vue
Normal file
9
src/platform/assets/components/MediaOtherTop.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="relative size-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
>
|
||||
<i class="icon-[lucide--check-check] text-3xl text-base-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
9
src/platform/assets/components/MediaTextTop.vue
Normal file
9
src/platform/assets/components/MediaTextTop.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="relative size-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
>
|
||||
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,7 +3,14 @@ import { z } from 'zod'
|
||||
|
||||
import { assetItemSchema } from './assetSchema'
|
||||
|
||||
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
|
||||
const zMediaKindSchema = z.enum([
|
||||
'video',
|
||||
'audio',
|
||||
'image',
|
||||
'3D',
|
||||
'text',
|
||||
'other'
|
||||
])
|
||||
export type MediaKind = z.infer<typeof zMediaKindSchema>
|
||||
|
||||
const zDimensionsSchema = z.object({
|
||||
|
||||
17
src/platform/assets/utils/mediaIconUtil.test.ts
Normal file
17
src/platform/assets/utils/mediaIconUtil.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { iconForMediaType } from './mediaIconUtil'
|
||||
|
||||
describe('iconForMediaType', () => {
|
||||
it('maps text and misc fallbacks correctly', () => {
|
||||
expect(iconForMediaType('text')).toBe('icon-[lucide--text]')
|
||||
expect(iconForMediaType('other')).toBe('icon-[lucide--check-check]')
|
||||
})
|
||||
|
||||
it('preserves existing mappings for core media types', () => {
|
||||
expect(iconForMediaType('image')).toBe('icon-[lucide--image]')
|
||||
expect(iconForMediaType('video')).toBe('icon-[lucide--video]')
|
||||
expect(iconForMediaType('audio')).toBe('icon-[lucide--music]')
|
||||
expect(iconForMediaType('3D')).toBe('icon-[lucide--box]')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,10 @@ export function iconForMediaType(mediaType: MediaKind): string {
|
||||
return 'icon-[lucide--music]'
|
||||
case '3D':
|
||||
return 'icon-[lucide--box]'
|
||||
case 'text':
|
||||
return 'icon-[lucide--text]'
|
||||
case 'other':
|
||||
return 'icon-[lucide--check-check]'
|
||||
default:
|
||||
return 'icon-[lucide--image]'
|
||||
}
|
||||
|
||||
75
src/platform/surveys/surveyRegistry.test.ts
Normal file
75
src/platform/surveys/surveyRegistry.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { FeatureSurveyConfig } from './useSurveyEligibility'
|
||||
|
||||
import {
|
||||
FEATURE_SURVEYS,
|
||||
getEnabledSurveys,
|
||||
getSurveyConfig
|
||||
} from './surveyRegistry'
|
||||
|
||||
const TEST_FEATURE_ID = '__test-feature__'
|
||||
const TEST_CONFIG: FeatureSurveyConfig = {
|
||||
featureId: TEST_FEATURE_ID,
|
||||
typeformId: 'test-form-123',
|
||||
triggerThreshold: 5,
|
||||
delayMs: 3000,
|
||||
enabled: true
|
||||
}
|
||||
|
||||
describe('surveyRegistry', () => {
|
||||
let originalEntries: Record<string, FeatureSurveyConfig>
|
||||
|
||||
beforeEach(() => {
|
||||
originalEntries = { ...FEATURE_SURVEYS }
|
||||
FEATURE_SURVEYS[TEST_FEATURE_ID] = TEST_CONFIG
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(FEATURE_SURVEYS)) {
|
||||
delete FEATURE_SURVEYS[key]
|
||||
}
|
||||
Object.assign(FEATURE_SURVEYS, originalEntries)
|
||||
})
|
||||
|
||||
describe('getSurveyConfig', () => {
|
||||
it('returns undefined for unknown feature', () => {
|
||||
expect(getSurveyConfig('nonexistent-feature')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns config for registered feature', () => {
|
||||
const config = getSurveyConfig(TEST_FEATURE_ID)
|
||||
expect(config).toEqual(TEST_CONFIG)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEnabledSurveys', () => {
|
||||
it('includes surveys with enabled: true', () => {
|
||||
const enabled = getEnabledSurveys()
|
||||
expect(enabled).toContainEqual(TEST_CONFIG)
|
||||
})
|
||||
|
||||
it('includes surveys where enabled is undefined', () => {
|
||||
const implicitlyEnabled: FeatureSurveyConfig = {
|
||||
featureId: '__implicit__',
|
||||
typeformId: 'form-456'
|
||||
}
|
||||
FEATURE_SURVEYS['__implicit__'] = implicitlyEnabled
|
||||
|
||||
const enabled = getEnabledSurveys()
|
||||
expect(enabled).toContainEqual(implicitlyEnabled)
|
||||
})
|
||||
|
||||
it('excludes surveys with enabled: false', () => {
|
||||
const disabledConfig: FeatureSurveyConfig = {
|
||||
featureId: '__disabled__',
|
||||
typeformId: 'form-789',
|
||||
enabled: false
|
||||
}
|
||||
FEATURE_SURVEYS['__disabled__'] = disabledConfig
|
||||
|
||||
const enabled = getEnabledSurveys()
|
||||
expect(enabled).not.toContainEqual(disabledConfig)
|
||||
})
|
||||
})
|
||||
})
|
||||
19
src/platform/surveys/surveyRegistry.ts
Normal file
19
src/platform/surveys/surveyRegistry.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { FeatureSurveyConfig } from './useSurveyEligibility'
|
||||
|
||||
/**
|
||||
* Registry of all feature surveys.
|
||||
* Add new surveys here when targeting specific features for feedback.
|
||||
*/
|
||||
export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {}
|
||||
|
||||
export function getSurveyConfig(
|
||||
featureId: string
|
||||
): FeatureSurveyConfig | undefined {
|
||||
return FEATURE_SURVEYS[featureId]
|
||||
}
|
||||
|
||||
export function getEnabledSurveys(): FeatureSurveyConfig[] {
|
||||
return Object.values(FEATURE_SURVEYS).filter(
|
||||
(config) => config.enabled !== false
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
|
||||
|
||||
interface FeatureSurveyConfig {
|
||||
export interface FeatureSurveyConfig {
|
||||
/** Feature identifier. Must remain static after initialization. */
|
||||
featureId: string
|
||||
typeformId: string
|
||||
|
||||
@@ -106,8 +106,13 @@ export class ComfyWorkflow extends UserFile {
|
||||
await super.load({ force })
|
||||
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
|
||||
|
||||
if (!this.originalContent) {
|
||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||
if (this.originalContent == null) {
|
||||
throw new Error(
|
||||
`[ASSERT] Workflow content should be loaded for '${this.path}'`
|
||||
)
|
||||
}
|
||||
if (this.originalContent.trim().length === 0) {
|
||||
throw new Error(`Workflow content is empty for '${this.path}'`)
|
||||
}
|
||||
|
||||
const initialState = JSON.parse(this.originalContent)
|
||||
|
||||
@@ -22,6 +22,7 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -29,6 +30,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
@@ -79,7 +81,7 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
return {
|
||||
...nodeData,
|
||||
//note lastNodeErrors uses exeuctionid, node.id is execution for root
|
||||
hasErrors: !!executionStore.lastNodeErrors?.[node.id],
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
|
||||
@@ -246,7 +246,7 @@ import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useN
|
||||
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
@@ -293,9 +293,9 @@ const isSelected = computed(() => {
|
||||
|
||||
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
||||
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const hasExecutionError = computed(
|
||||
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
||||
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
|
||||
)
|
||||
|
||||
const hasAnyError = computed((): boolean => {
|
||||
@@ -303,7 +303,7 @@ const hasAnyError = computed((): boolean => {
|
||||
hasExecutionError.value ||
|
||||
nodeData.hasErrors ||
|
||||
error ||
|
||||
(executionStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
|
||||
(executionErrorStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -116,7 +116,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
if (shouldHandleNodePointerEvents.value) return
|
||||
@@ -170,7 +170,7 @@ interface ProcessedWidget {
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
const nodeErrors = executionStore.lastNodeErrors?.[nodeData.id ?? '']
|
||||
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeData.id ?? '']
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const { widgets } = nodeData
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -38,7 +39,7 @@ describe('API Feature Flags', () => {
|
||||
})
|
||||
|
||||
// Reset API state
|
||||
api.serverFeatureFlags = {}
|
||||
api.serverFeatureFlags.value = {}
|
||||
|
||||
// Mock getClientFeatureFlags to return test feature flags
|
||||
vi.spyOn(api, 'getClientFeatureFlags').mockReturnValue({
|
||||
@@ -102,7 +103,7 @@ describe('API Feature Flags', () => {
|
||||
await initPromise
|
||||
|
||||
// Check that server features were stored
|
||||
expect(api.serverFeatureFlags).toEqual({
|
||||
expect(api.serverFeatureFlags.value).toEqual({
|
||||
supports_preview_metadata: true,
|
||||
async_execution: true,
|
||||
supported_formats: ['webp', 'jpeg', 'png'],
|
||||
@@ -144,14 +145,14 @@ describe('API Feature Flags', () => {
|
||||
await initPromise
|
||||
|
||||
// Server features should remain empty
|
||||
expect(api.serverFeatureFlags).toEqual({})
|
||||
expect(api.serverFeatureFlags.value).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feature checking methods', () => {
|
||||
beforeEach(() => {
|
||||
// Set up some test features
|
||||
api.serverFeatureFlags = {
|
||||
api.serverFeatureFlags.value = {
|
||||
supports_preview_metadata: true,
|
||||
async_execution: false,
|
||||
capabilities: ['isolated_nodes', 'dynamic_models']
|
||||
@@ -208,12 +209,28 @@ describe('API Feature Flags', () => {
|
||||
describe('Integration with preview messages', () => {
|
||||
it('should affect preview message handling based on feature support', () => {
|
||||
// Test with metadata support
|
||||
api.serverFeatureFlags = { supports_preview_metadata: true }
|
||||
api.serverFeatureFlags.value = { supports_preview_metadata: true }
|
||||
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
|
||||
|
||||
// Test without metadata support
|
||||
api.serverFeatureFlags = {}
|
||||
api.serverFeatureFlags.value = {}
|
||||
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reactivity', () => {
|
||||
it('should trigger computed updates when serverFeatureFlags changes', async () => {
|
||||
api.serverFeatureFlags.value = {}
|
||||
|
||||
const flag = computed(() =>
|
||||
api.getServerFeature('supports_preview_metadata', false)
|
||||
)
|
||||
expect(flag.value).toBe(false)
|
||||
|
||||
api.serverFeatureFlags.value = { supports_preview_metadata: true }
|
||||
await nextTick()
|
||||
|
||||
expect(flag.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { promiseTimeout, until } from '@vueuse/core'
|
||||
import axios from 'axios'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import { trimEnd } from 'es-toolkit'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
|
||||
import type {
|
||||
@@ -340,7 +341,7 @@ export class ComfyApi extends EventTarget {
|
||||
/**
|
||||
* Feature flags received from the backend server.
|
||||
*/
|
||||
serverFeatureFlags: Record<string, unknown> = {}
|
||||
serverFeatureFlags = ref<Record<string, unknown>>({})
|
||||
|
||||
/**
|
||||
* The auth token for the comfy org account if the user is logged in.
|
||||
@@ -695,10 +696,10 @@ export class ComfyApi extends EventTarget {
|
||||
break
|
||||
case 'feature_flags':
|
||||
// Store server feature flags
|
||||
this.serverFeatureFlags = msg.data
|
||||
this.serverFeatureFlags.value = msg.data
|
||||
console.log(
|
||||
'Server feature flags received:',
|
||||
this.serverFeatureFlags
|
||||
this.serverFeatureFlags.value
|
||||
)
|
||||
this.dispatchCustomEvent('feature_flags', msg.data)
|
||||
break
|
||||
@@ -1180,9 +1181,16 @@ export class ComfyApi extends EventTarget {
|
||||
|
||||
async getGlobalSubgraphData(id: string): Promise<string> {
|
||||
const resp = await api.fetchApi('/global_subgraphs/' + id)
|
||||
if (resp.status !== 200) return ''
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to fetch global subgraph '${id}': ${resp.status} ${resp.statusText}`
|
||||
)
|
||||
}
|
||||
const subgraph: GlobalSubgraphData = await resp.json()
|
||||
return subgraph?.data ?? ''
|
||||
if (!subgraph?.data) {
|
||||
throw new Error(`Global subgraph '${id}' returned empty data`)
|
||||
}
|
||||
return subgraph.data as string
|
||||
}
|
||||
async getGlobalSubgraphs(): Promise<Record<string, GlobalSubgraphData>> {
|
||||
const resp = await api.fetchApi('/global_subgraphs')
|
||||
@@ -1291,7 +1299,7 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns true if the feature is supported, false otherwise
|
||||
*/
|
||||
serverSupportsFeature(featureName: string): boolean {
|
||||
return get(this.serverFeatureFlags, featureName) === true
|
||||
return get(this.serverFeatureFlags.value, featureName) === true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1301,7 +1309,7 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns The feature value or default
|
||||
*/
|
||||
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
|
||||
return get(this.serverFeatureFlags, featureName, defaultValue) as T
|
||||
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1309,7 +1317,7 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns Copy of all server feature flags
|
||||
*/
|
||||
getServerFeatures(): Record<string, unknown> {
|
||||
return { ...this.serverFeatureFlags }
|
||||
return { ...this.serverFeatureFlags.value }
|
||||
}
|
||||
|
||||
async getFuseOptions(): Promise<IFuseOptions<TemplateInfo> | null> {
|
||||
|
||||
@@ -60,6 +60,7 @@ import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
@@ -218,18 +219,18 @@ export class ComfyApp {
|
||||
|
||||
/**
|
||||
* The node errors from the previous execution.
|
||||
* @deprecated Use useExecutionStore().lastNodeErrors instead
|
||||
* @deprecated Use app.extensionManager.lastNodeErrors instead
|
||||
*/
|
||||
get lastNodeErrors(): Record<NodeId, NodeError> | null {
|
||||
return useExecutionStore().lastNodeErrors
|
||||
return useExecutionErrorStore().lastNodeErrors
|
||||
}
|
||||
|
||||
/**
|
||||
* The error from the previous execution.
|
||||
* @deprecated Use useExecutionStore().lastExecutionError instead
|
||||
* @deprecated Use app.extensionManager.lastExecutionError instead
|
||||
*/
|
||||
get lastExecutionError(): ExecutionErrorWsMessage | null {
|
||||
return useExecutionStore().lastExecutionError
|
||||
return useExecutionErrorStore().lastExecutionError
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -713,7 +714,7 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
} else if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
useExecutionStore().showErrorOverlay()
|
||||
useExecutionErrorStore().showErrorOverlay()
|
||||
} else {
|
||||
useDialogService().showExecutionErrorDialog(detail)
|
||||
}
|
||||
@@ -1402,9 +1403,8 @@ export class ComfyApp {
|
||||
|
||||
this.processingQueue = true
|
||||
const executionStore = useExecutionStore()
|
||||
executionStore.lastNodeErrors = null
|
||||
executionStore.lastExecutionError = null
|
||||
executionStore.lastPromptError = null
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.clearAllErrors()
|
||||
|
||||
// Get auth token for backend nodes - uses workspace token if enabled, otherwise Firebase token
|
||||
const comfyOrgAuthToken = await useFirebaseAuthStore().getAuthToken()
|
||||
@@ -1440,8 +1440,8 @@ export class ComfyApp {
|
||||
})
|
||||
delete api.authToken
|
||||
delete api.apiKey
|
||||
executionStore.lastNodeErrors = res.node_errors ?? null
|
||||
if (executionStore.lastNodeErrors?.length) {
|
||||
executionErrorStore.lastNodeErrors = res.node_errors ?? null
|
||||
if (executionErrorStore.lastNodeErrors?.length) {
|
||||
this.canvas.draw(true, true)
|
||||
} else {
|
||||
try {
|
||||
@@ -1477,7 +1477,8 @@ export class ComfyApp {
|
||||
console.error(error)
|
||||
|
||||
if (error instanceof PromptExecutionError) {
|
||||
executionStore.lastNodeErrors = error.response.node_errors ?? null
|
||||
executionErrorStore.lastNodeErrors =
|
||||
error.response.node_errors ?? null
|
||||
|
||||
// Store prompt-level error separately only when no node-specific errors exist,
|
||||
// because node errors already carry the full context. Prompt-level errors
|
||||
@@ -1489,13 +1490,13 @@ export class ComfyApp {
|
||||
if (!hasNodeErrors) {
|
||||
const respError = error.response.error
|
||||
if (respError && typeof respError === 'object') {
|
||||
executionStore.lastPromptError = {
|
||||
executionErrorStore.lastPromptError = {
|
||||
type: respError.type,
|
||||
message: respError.message,
|
||||
details: respError.details ?? ''
|
||||
}
|
||||
} else if (typeof respError === 'string') {
|
||||
executionStore.lastPromptError = {
|
||||
executionErrorStore.lastPromptError = {
|
||||
type: 'error',
|
||||
message: respError,
|
||||
details: ''
|
||||
@@ -1504,7 +1505,7 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
executionStore.showErrorOverlay()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
}
|
||||
@@ -1533,7 +1534,7 @@ export class ComfyApp {
|
||||
} finally {
|
||||
this.processingQueue = false
|
||||
}
|
||||
return !executionStore.lastNodeErrors
|
||||
return !executionErrorStore.lastNodeErrors
|
||||
}
|
||||
|
||||
showErrorOnFileLoad(file: File) {
|
||||
@@ -1880,10 +1881,8 @@ export class ComfyApp {
|
||||
clean() {
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
nodeOutputStore.resetAllOutputsAndPreviews()
|
||||
const executionStore = useExecutionStore()
|
||||
executionStore.lastNodeErrors = null
|
||||
executionStore.lastExecutionError = null
|
||||
executionStore.lastPromptError = null
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.clearAllErrors()
|
||||
|
||||
useDomWidgetStore().clear()
|
||||
|
||||
|
||||
280
src/stores/executionErrorStore.ts
Normal file
280
src/stores/executionErrorStore.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
executionIdToNodeLocatorId,
|
||||
forEachNode,
|
||||
getNodeByExecutionId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Store dedicated to execution error state management.
|
||||
*
|
||||
* Extracted from executionStore to separate error-related concerns
|
||||
* (state, computed properties, graph flag propagation, overlay UI)
|
||||
* from execution flow management (progress, queuing, events).
|
||||
*/
|
||||
export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
const lastPromptError = ref<PromptError | null>(null)
|
||||
|
||||
const isErrorOverlayOpen = ref(false)
|
||||
|
||||
function showErrorOverlay() {
|
||||
isErrorOverlayOpen.value = true
|
||||
}
|
||||
|
||||
function dismissErrorOverlay() {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
/** Clear all error state. Called at execution start. */
|
||||
function clearAllErrors() {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
lastNodeErrors.value = null
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
/** Clear only prompt-level errors. Called during resetExecutionState. */
|
||||
function clearPromptError() {
|
||||
lastPromptError.value = null
|
||||
}
|
||||
|
||||
const lastExecutionErrorNodeLocatorId = computed(() => {
|
||||
const err = lastExecutionError.value
|
||||
if (!err) return null
|
||||
return executionIdToNodeLocatorId(app.rootGraph, String(err.node_id))
|
||||
})
|
||||
|
||||
const lastExecutionErrorNodeId = computed(() => {
|
||||
const locator = lastExecutionErrorNodeLocatorId.value
|
||||
if (!locator) return null
|
||||
const localId = workflowStore.nodeLocatorIdToNodeId(locator)
|
||||
return localId != null ? String(localId) : null
|
||||
})
|
||||
|
||||
/** Whether a runtime execution error is present */
|
||||
const hasExecutionError = computed(() => !!lastExecutionError.value)
|
||||
|
||||
/** Whether a prompt-level error is present (e.g. invalid_prompt, prompt_no_outputs) */
|
||||
const hasPromptError = computed(() => !!lastPromptError.value)
|
||||
|
||||
/** Whether any node validation errors are present */
|
||||
const hasNodeError = computed(
|
||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||
)
|
||||
|
||||
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
|
||||
const hasAnyError = computed(
|
||||
() => 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. */
|
||||
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
|
||||
|
||||
if (lastNodeErrors.value) {
|
||||
for (const executionId of Object.keys(lastNodeErrors.value)) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastExecutionError.value) {
|
||||
const execNodeId = String(lastExecutionError.value.node_id)
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, execNodeId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
})
|
||||
|
||||
/** Map of node errors indexed by locator ID. */
|
||||
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
||||
() => {
|
||||
if (!lastNodeErrors.value) return {}
|
||||
|
||||
const map: Record<NodeLocatorId, NodeError> = {}
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (locatorId) {
|
||||
map[locatorId] = nodeError
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
)
|
||||
|
||||
/** Get node errors by locator ID. */
|
||||
const getNodeErrors = (
|
||||
nodeLocatorId: NodeLocatorId
|
||||
): NodeError | undefined => {
|
||||
return nodeErrorsByLocatorId.value[nodeLocatorId]
|
||||
}
|
||||
|
||||
/** Check if a specific slot has validation errors. */
|
||||
const slotHasError = (
|
||||
nodeLocatorId: NodeLocatorId,
|
||||
slotName: string
|
||||
): boolean => {
|
||||
const nodeError = getNodeErrors(nodeLocatorId)
|
||||
if (!nodeError) return false
|
||||
|
||||
return nodeError.errors.some((e) => e.extra_info?.input_name === slotName)
|
||||
}
|
||||
|
||||
function hasInternalErrorForNode(nodeId: string | number): boolean {
|
||||
const prefix = `${nodeId}:`
|
||||
return allErrorExecutionIds.value.some((id) => id.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node and slot error flags when validation errors change.
|
||||
* Propagates errors up subgraph chains.
|
||||
*/
|
||||
watch(lastNodeErrors, () => {
|
||||
if (!app.rootGraph) return
|
||||
|
||||
// Clear all error flags
|
||||
forEachNode(app.rootGraph, (node) => {
|
||||
node.has_errors = false
|
||||
if (node.inputs) {
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!lastNodeErrors.value) return
|
||||
|
||||
// Set error flags on nodes and slots
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (!node) continue
|
||||
|
||||
node.has_errors = true
|
||||
|
||||
// Mark input slots with errors
|
||||
if (node.inputs) {
|
||||
for (const error of nodeError.errors) {
|
||||
const slotName = error.extra_info?.input_name
|
||||
if (!slotName) continue
|
||||
|
||||
const slot = node.inputs.find((s) => s.name === slotName)
|
||||
if (slot) {
|
||||
slot.hasErrors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate errors to parent subgraph nodes
|
||||
const parts = executionId.split(':')
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const parentExecutionId = parts.slice(0, i).join(':')
|
||||
const parentNode = getNodeByExecutionId(
|
||||
app.rootGraph,
|
||||
parentExecutionId
|
||||
)
|
||||
if (parentNode) {
|
||||
parentNode.has_errors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// Raw state
|
||||
lastNodeErrors,
|
||||
lastExecutionError,
|
||||
lastPromptError,
|
||||
|
||||
// Clearing
|
||||
clearAllErrors,
|
||||
clearPromptError,
|
||||
|
||||
// Overlay UI
|
||||
isErrorOverlayOpen,
|
||||
showErrorOverlay,
|
||||
dismissErrorOverlay,
|
||||
|
||||
// Derived state
|
||||
hasExecutionError,
|
||||
hasPromptError,
|
||||
hasNodeError,
|
||||
hasAnyError,
|
||||
allErrorExecutionIds,
|
||||
totalErrorCount,
|
||||
lastExecutionErrorNodeId,
|
||||
activeGraphErrorNodeIds,
|
||||
|
||||
// Lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError,
|
||||
hasInternalErrorForNode
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
// Create mock functions that will be shared
|
||||
const mockNodeExecutionIdToNodeLocatorId = vi.fn()
|
||||
@@ -80,20 +82,20 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
// Mock app.rootGraph.getNodeById to return the mock node
|
||||
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
const result = store.executionIdToNodeLocatorId('123:456')
|
||||
const result = executionIdToNodeLocatorId(app.rootGraph, '123:456')
|
||||
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
})
|
||||
|
||||
it('should convert simple node ID to NodeLocatorId', () => {
|
||||
const result = store.executionIdToNodeLocatorId('123')
|
||||
const result = executionIdToNodeLocatorId(app.rootGraph, '123')
|
||||
|
||||
// For simple node IDs, it should return the ID as-is
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should handle numeric node IDs', () => {
|
||||
const result = store.executionIdToNodeLocatorId(123)
|
||||
const result = executionIdToNodeLocatorId(app.rootGraph, 123)
|
||||
|
||||
// For numeric IDs, it should convert to string and return as-is
|
||||
expect(result).toBe('123')
|
||||
@@ -103,7 +105,9 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
// Mock app.rootGraph.getNodeById to return null (node not found)
|
||||
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(null)
|
||||
|
||||
expect(store.executionIdToNodeLocatorId('999:456')).toBe(undefined)
|
||||
expect(executionIdToNodeLocatorId(app.rootGraph, '999:456')).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -174,13 +178,13 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - Node Error Lookups', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
let store: ReturnType<typeof useExecutionErrorStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store = useExecutionErrorStore()
|
||||
})
|
||||
|
||||
describe('getNodeErrors', () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -20,22 +19,20 @@ import type {
|
||||
ExecutionInterruptedWsMessage,
|
||||
ExecutionStartWsMessage,
|
||||
ExecutionSuccessWsMessage,
|
||||
NodeError,
|
||||
NodeProgressState,
|
||||
NotificationWsMessage,
|
||||
ProgressStateWsMessage,
|
||||
ProgressTextWsMessage,
|
||||
ProgressWsMessage,
|
||||
PromptError
|
||||
ProgressWsMessage
|
||||
} from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface QueuedJob {
|
||||
/**
|
||||
@@ -49,73 +46,14 @@ interface QueuedJob {
|
||||
workflow?: ComfyWorkflow
|
||||
}
|
||||
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively get the subgraph objects for the given subgraph instance IDs
|
||||
* @param currentGraph The current graph
|
||||
* @param subgraphNodeIds The instance IDs
|
||||
* @param subgraphs The subgraphs
|
||||
* @returns The subgraphs that correspond to each of the instance IDs.
|
||||
*/
|
||||
function getSubgraphsFromInstanceIds(
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] | undefined {
|
||||
// Last segment is the node portion; nothing to do.
|
||||
if (subgraphNodeIds.length === 1) return subgraphs
|
||||
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (!subgraph) {
|
||||
console.warn(`Subgraph not found: ${currentPart}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert execution context node IDs to NodeLocatorIds
|
||||
* @param nodeId The node ID from execution context (could be execution ID)
|
||||
* @returns The NodeLocatorId
|
||||
*/
|
||||
function executionIdToNodeLocatorId(
|
||||
nodeId: string | number
|
||||
): NodeLocatorId | undefined {
|
||||
const nodeIdStr = String(nodeId)
|
||||
|
||||
if (!nodeIdStr.includes(':')) {
|
||||
// It's a top-level node ID
|
||||
return nodeIdStr
|
||||
}
|
||||
|
||||
// It's an execution node ID
|
||||
const parts = nodeIdStr.split(':')
|
||||
const localNodeId = parts[parts.length - 1]
|
||||
const subgraphs = getSubgraphsFromInstanceIds(app.rootGraph, parts)
|
||||
if (!subgraphs) return undefined
|
||||
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
|
||||
return nodeLocatorId
|
||||
}
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
const clientId = ref<string | null>(null)
|
||||
const activeJobId = ref<string | null>(null)
|
||||
const queuedJobs = ref<Record<NodeId, QueuedJob>>({})
|
||||
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<
|
||||
@@ -168,7 +106,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const parts = String(state.display_node_id).split(':')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const executionId = parts.slice(0, i + 1).join(':')
|
||||
const locatorId = executionIdToNodeLocatorId(executionId)
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!locatorId) continue
|
||||
|
||||
result[locatorId] = mergeExecutionProgressStates(
|
||||
@@ -245,19 +183,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return total > 0 ? done / total : 0
|
||||
})
|
||||
|
||||
const lastExecutionErrorNodeLocatorId = computed(() => {
|
||||
const err = lastExecutionError.value
|
||||
if (!err) return null
|
||||
return executionIdToNodeLocatorId(String(err.node_id))
|
||||
})
|
||||
|
||||
const lastExecutionErrorNodeId = computed(() => {
|
||||
const locator = lastExecutionErrorNodeLocatorId.value
|
||||
if (!locator) return null
|
||||
const localId = workflowStore.nodeLocatorIdToNodeId(locator)
|
||||
return localId != null ? String(localId) : null
|
||||
})
|
||||
|
||||
function bindExecutionEvents() {
|
||||
api.addEventListener('notification', handleNotification)
|
||||
api.addEventListener('execution_start', handleExecutionStart)
|
||||
@@ -289,10 +214,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
lastNodeErrors.value = null
|
||||
isErrorOverlayOpen.value = false
|
||||
executionErrorStore.clearAllErrors()
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
clearInitializationByJobId(activeJobId.value)
|
||||
@@ -410,7 +332,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (handleServiceLevelError(e.detail)) return
|
||||
|
||||
// OSS path / Cloud fallback (real runtime errors)
|
||||
lastExecutionError.value = e.detail
|
||||
executionErrorStore.lastExecutionError = e.detail
|
||||
clearInitializationByJobId(e.detail.prompt_id)
|
||||
resetExecutionState(e.detail.prompt_id)
|
||||
}
|
||||
@@ -422,7 +344,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
clearInitializationByJobId(detail.prompt_id)
|
||||
resetExecutionState(detail.prompt_id)
|
||||
lastPromptError.value = {
|
||||
executionErrorStore.lastPromptError = {
|
||||
type: detail.exception_type ?? 'error',
|
||||
message: detail.exception_type
|
||||
? `${detail.exception_type}: ${detail.exception_message}`
|
||||
@@ -442,9 +364,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
resetExecutionState(detail.prompt_id)
|
||||
|
||||
if (result.kind === 'nodeErrors') {
|
||||
lastNodeErrors.value = result.nodeErrors
|
||||
executionErrorStore.lastNodeErrors = result.nodeErrors
|
||||
} else {
|
||||
lastPromptError.value = result.promptError
|
||||
executionErrorStore.lastPromptError = result.promptError
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -515,7 +437,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
activeJobId.value = null
|
||||
_executingNodeProgress.value = null
|
||||
lastPromptError.value = null
|
||||
executionErrorStore.clearPromptError()
|
||||
}
|
||||
|
||||
function getNodeIdIfExecuting(nodeId: string | number) {
|
||||
@@ -596,207 +518,11 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => runningJobIds.value.length
|
||||
)
|
||||
|
||||
/** Map of node errors indexed by locator ID. */
|
||||
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
||||
() => {
|
||||
if (!lastNodeErrors.value) return {}
|
||||
|
||||
const map: Record<NodeLocatorId, NodeError> = {}
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const locatorId = executionIdToNodeLocatorId(executionId)
|
||||
if (locatorId) {
|
||||
map[locatorId] = nodeError
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
)
|
||||
|
||||
/** Get node errors by locator ID. */
|
||||
const getNodeErrors = (
|
||||
nodeLocatorId: NodeLocatorId
|
||||
): NodeError | undefined => {
|
||||
return nodeErrorsByLocatorId.value[nodeLocatorId]
|
||||
}
|
||||
|
||||
/** Check if a specific slot has validation errors. */
|
||||
const slotHasError = (
|
||||
nodeLocatorId: NodeLocatorId,
|
||||
slotName: string
|
||||
): boolean => {
|
||||
const nodeError = getNodeErrors(nodeLocatorId)
|
||||
if (!nodeError) return false
|
||||
|
||||
return nodeError.errors.some((e) => e.extra_info?.input_name === slotName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node and slot error flags when validation errors change.
|
||||
* Propagates errors up subgraph chains.
|
||||
*/
|
||||
watch(lastNodeErrors, () => {
|
||||
if (!app.rootGraph) return
|
||||
|
||||
// Clear all error flags
|
||||
forEachNode(app.rootGraph, (node) => {
|
||||
node.has_errors = false
|
||||
if (node.inputs) {
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!lastNodeErrors.value) return
|
||||
|
||||
// Set error flags on nodes and slots
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (!node) continue
|
||||
|
||||
node.has_errors = true
|
||||
|
||||
// Mark input slots with errors
|
||||
if (node.inputs) {
|
||||
for (const error of nodeError.errors) {
|
||||
const slotName = error.extra_info?.input_name
|
||||
if (!slotName) continue
|
||||
|
||||
const slot = node.inputs.find((s) => s.name === slotName)
|
||||
if (slot) {
|
||||
slot.hasErrors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate errors to parent subgraph nodes
|
||||
const parts = executionId.split(':')
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const parentExecutionId = parts.slice(0, i).join(':')
|
||||
const parentNode = getNodeByExecutionId(
|
||||
app.rootGraph,
|
||||
parentExecutionId
|
||||
)
|
||||
if (parentNode) {
|
||||
parentNode.has_errors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** Whether a runtime execution error is present */
|
||||
const hasExecutionError = computed(() => !!lastExecutionError.value)
|
||||
|
||||
/** Whether a prompt-level error is present (e.g. invalid_prompt, prompt_no_outputs) */
|
||||
const hasPromptError = computed(() => !!lastPromptError.value)
|
||||
|
||||
/** Whether any node validation errors are present */
|
||||
const hasNodeError = computed(
|
||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||
)
|
||||
|
||||
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
|
||||
const hasAnyError = computed(
|
||||
() => 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. */
|
||||
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
|
||||
|
||||
if (lastNodeErrors.value) {
|
||||
for (const executionId of Object.keys(lastNodeErrors.value)) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastExecutionError.value) {
|
||||
const execNodeId = String(lastExecutionError.value.node_id)
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, execNodeId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
lastNodeErrors,
|
||||
lastExecutionError,
|
||||
lastPromptError,
|
||||
hasAnyError,
|
||||
allErrorExecutionIds,
|
||||
totalErrorCount,
|
||||
lastExecutionErrorNodeId,
|
||||
executingNodeId,
|
||||
executingNodeIds,
|
||||
activeJob,
|
||||
@@ -823,16 +549,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// Raw executing progress data for backward compatibility in ComfyApp.
|
||||
_executingNodeProgress,
|
||||
// NodeLocatorId conversion helpers
|
||||
executionIdToNodeLocatorId,
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId,
|
||||
// Node error lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError,
|
||||
hasInternalErrorForNode,
|
||||
activeGraphErrorNodeIds,
|
||||
isErrorOverlayOpen,
|
||||
showErrorOverlay,
|
||||
dismissErrorOverlay
|
||||
jobIdToWorkflowId
|
||||
}
|
||||
})
|
||||
|
||||
@@ -38,10 +38,8 @@ const createMockOutputs = (
|
||||
images?: ExecutedWsMessage['output']['images']
|
||||
): ExecutedWsMessage['output'] => ({ images })
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: vi.fn(() => ({
|
||||
executionIdToNodeLocatorId: vi.fn((id: string) => id)
|
||||
}))
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
} from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { parseFilePath } from '@/utils/formatUtil'
|
||||
import { isAnimatedOutput, isVideoNode } from '@/utils/litegraphUtil'
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
releaseSharedObjectUrl,
|
||||
retainSharedObjectUrl
|
||||
} from '@/utils/objectUrlUtil'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
const PREVIEW_REVOKE_DELAY_MS = 400
|
||||
|
||||
@@ -43,7 +43,6 @@ interface SetOutputOptions {
|
||||
|
||||
export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore()
|
||||
const { executionIdToNodeLocatorId } = useExecutionStore()
|
||||
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
|
||||
const latestPreview = ref<string[]>([])
|
||||
|
||||
@@ -202,7 +201,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
outputs: ExecutedWsMessage['output'] | ResultItem,
|
||||
options: SetOutputOptions = {}
|
||||
) {
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!nodeLocatorId) return
|
||||
|
||||
setOutputsByLocatorId(nodeLocatorId, outputs, options)
|
||||
@@ -219,7 +218,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
executionId: string,
|
||||
previewImages: string[]
|
||||
) {
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!nodeLocatorId) return
|
||||
const existingPreviews = app.nodePreviewImages[nodeLocatorId]
|
||||
if (scheduledRevoke[nodeLocatorId]) {
|
||||
@@ -275,7 +274,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
* @param executionId - The execution ID
|
||||
*/
|
||||
function revokePreviewsByExecutionId(executionId: string) {
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!nodeLocatorId) return
|
||||
scheduleRevoke(nodeLocatorId, () =>
|
||||
revokePreviewsByLocatorId(nodeLocatorId)
|
||||
|
||||
@@ -225,6 +225,68 @@ describe('useSubgraphStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle global blueprint with empty data gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
broken_blueprint: {
|
||||
name: 'Broken Blueprint',
|
||||
info: { node_pack: 'test_pack' },
|
||||
data: ''
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load subgraph blueprint',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.subgraphBlueprints).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle global blueprint with rejected data promise gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
failing_blueprint: {
|
||||
name: 'Failing Blueprint',
|
||||
info: { node_pack: 'test_pack' },
|
||||
data: Promise.reject(new Error('Network error')) as unknown as string
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load subgraph blueprint',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.subgraphBlueprints).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should load valid global blueprints even when others fail', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
broken: {
|
||||
name: 'Broken',
|
||||
info: { node_pack: 'test_pack' },
|
||||
data: ''
|
||||
},
|
||||
valid: {
|
||||
name: 'Valid Blueprint',
|
||||
info: { node_pack: 'test_pack' },
|
||||
data: JSON.stringify(mockGraph)
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
expect(store.subgraphBlueprints).toHaveLength(1)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('search_aliases support', () => {
|
||||
it('should include search_aliases from workflow extra', async () => {
|
||||
const mockGraphWithAliases = {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates
|
||||
import { api } from '@/scripts/api'
|
||||
import type { GlobalSubgraphData } from '@/scripts/api'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { UserFile } from '@/stores/userFileStore'
|
||||
|
||||
@@ -79,7 +79,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
useExecutionStore().lastNodeErrors = errors
|
||||
useExecutionErrorStore().lastNodeErrors = errors
|
||||
useCanvasStore().getCanvas().draw(true, true)
|
||||
throw new Error(
|
||||
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
|
||||
@@ -198,13 +198,19 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
}
|
||||
async function loadInstalledBlueprints() {
|
||||
async function loadGlobalBlueprint([k, v]: [string, GlobalSubgraphData]) {
|
||||
const data = await v.data
|
||||
if (typeof data !== 'string' || data.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Global blueprint '${v.name}' (${k}) returned empty content`
|
||||
)
|
||||
}
|
||||
const path = SubgraphBlueprint.basePath + v.name + '.json'
|
||||
const blueprint = new SubgraphBlueprint({
|
||||
path,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
blueprint.originalContent = blueprint.content = await v.data
|
||||
blueprint.originalContent = blueprint.content = data
|
||||
blueprint.filename = v.name
|
||||
useWorkflowStore().attachWorkflow(blueprint)
|
||||
const loaded = await blueprint.load()
|
||||
@@ -238,16 +244,19 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
return false
|
||||
return true
|
||||
})
|
||||
await Promise.allSettled(filteredEntries.map(loadGlobalBlueprint))
|
||||
return Promise.allSettled(filteredEntries.map(loadGlobalBlueprint))
|
||||
}
|
||||
|
||||
const userSubs = (
|
||||
await api.listUserDataFullInfo(SubgraphBlueprint.basePath)
|
||||
).filter((f) => f.path.endsWith('.json'))
|
||||
const settled = await Promise.allSettled([
|
||||
...userSubs.map(loadBlueprint),
|
||||
loadInstalledBlueprints()
|
||||
const [globalResult, ...userResults] = await Promise.allSettled([
|
||||
loadInstalledBlueprints(),
|
||||
...userSubs.map(loadBlueprint)
|
||||
])
|
||||
const globalResults =
|
||||
globalResult.status === 'fulfilled' ? globalResult.value : []
|
||||
const settled = [...globalResults, ...userResults]
|
||||
|
||||
const errors = settled.filter((i) => 'reason' in i).map((i) => i.reason)
|
||||
errors.forEach((e) => console.error('Failed to load subgraph blueprint', e))
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
|
||||
|
||||
import { useApiKeyAuthStore } from './apiKeyAuthStore'
|
||||
import { useCommandStore } from './commandStore'
|
||||
import { useExecutionErrorStore } from './executionErrorStore'
|
||||
import { useFirebaseAuthStore } from './firebaseAuthStore'
|
||||
import { useQueueSettingsStore } from './queueStore'
|
||||
import { useBottomPanelStore } from './workspace/bottomPanelStore'
|
||||
@@ -86,6 +87,8 @@ function workspaceStoreSetup() {
|
||||
return sidebarTab.value.sidebarTabs
|
||||
}
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
return {
|
||||
spinner,
|
||||
shiftDown,
|
||||
@@ -104,6 +107,10 @@ function workspaceStoreSetup() {
|
||||
bottomPanel,
|
||||
user: partialUserStore,
|
||||
|
||||
// Execution error state (read-only, exposed for custom extensions)
|
||||
lastNodeErrors: computed(() => executionErrorStore.lastNodeErrors),
|
||||
lastExecutionError: computed(() => executionErrorStore.lastExecutionError),
|
||||
|
||||
registerSidebarTab,
|
||||
unregisterSidebarTab,
|
||||
getSidebarTabs
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ExecutionErrorWsMessage, NodeError } from '@/schemas/apiSchema'
|
||||
import type { useDialogService } from '@/services/dialogService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
|
||||
@@ -111,6 +113,10 @@ export interface ExtensionManager {
|
||||
get: <T = unknown>(id: string) => T | undefined
|
||||
set: <T = unknown>(id: string, value: T) => void
|
||||
}
|
||||
|
||||
// Execution error state (read-only)
|
||||
lastNodeErrors: Record<NodeId, NodeError> | null
|
||||
lastExecutionError: ExecutionErrorWsMessage | null
|
||||
}
|
||||
|
||||
export interface CommandManager {
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { parseNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createNodeLocatorId,
|
||||
parseNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
import { isSubgraphIoNode } from './typeGuardUtil'
|
||||
|
||||
@@ -359,6 +362,36 @@ export function getNodeByLocatorId(
|
||||
return targetSubgraph.getNodeById(localNodeId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert execution context node IDs to NodeLocatorIds.
|
||||
* Uses traverseSubgraphPath to resolve the subgraph chain.
|
||||
*
|
||||
* @param rootGraph - The root graph to resolve against
|
||||
* @param nodeId - The node ID from execution context (could be execution ID like "123:456:789")
|
||||
* @returns The NodeLocatorId, or undefined if resolution fails
|
||||
*/
|
||||
export function executionIdToNodeLocatorId(
|
||||
rootGraph: LGraph,
|
||||
nodeId: string | number
|
||||
): NodeLocatorId | undefined {
|
||||
const nodeIdStr = String(nodeId)
|
||||
|
||||
if (!nodeIdStr.includes(':')) {
|
||||
// It's a top-level node ID
|
||||
return nodeIdStr
|
||||
}
|
||||
|
||||
// It's an execution node ID — resolve subgraph path
|
||||
const parts = nodeIdStr.split(':')
|
||||
const localNodeId = parts.at(-1)!
|
||||
const subgraphPath = parts.slice(0, -1)
|
||||
|
||||
const targetGraph = traverseSubgraphPath(rootGraph, subgraphPath)
|
||||
if (!targetGraph) return undefined
|
||||
|
||||
return createNodeLocatorId(targetGraph.id, localNodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the root graph from any graph in the hierarchy.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user