mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-26 07:57:36 +00:00
Compare commits
10 Commits
cloud/v1.4
...
pysssss/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aefcc22767 | ||
|
|
cddb393d95 | ||
|
|
4e79625244 | ||
|
|
c0ef283a05 | ||
|
|
4fcbee9a8a | ||
|
|
d405002127 | ||
|
|
abd233d10d | ||
|
|
e1049a99a3 | ||
|
|
3da6e1766e | ||
|
|
52830a9e73 |
@@ -51,6 +51,20 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setServerFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setServerFlags({ [name]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -309,6 +309,50 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Empty graph defaults', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlag(
|
||||
'node_library_essentials_enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Defaults to Essentials when graph is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const essentialsBtn = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Essentials
|
||||
)
|
||||
await expect(essentialsBtn).toBeVisible()
|
||||
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Defaults to Most Relevant when graph has nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
@@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.push(node.widgets![0])
|
||||
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets![2] = node.widgets![0]
|
||||
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.splice(0, 0, node.widgets![0])
|
||||
node.widgets!.splice(0, 0, {
|
||||
...node.widgets![0],
|
||||
name: 'added_widget_3'
|
||||
})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.14",
|
||||
"version": "1.46.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -406,9 +406,7 @@
|
||||
--secondary-background-selected
|
||||
);
|
||||
--component-node-widget-background-selected: var(--color-charcoal-100);
|
||||
--component-node-widget-background-disabled: var(
|
||||
--color-alpha-charcoal-600-30
|
||||
);
|
||||
--component-node-widget-background-disabled: var(--color-charcoal-800);
|
||||
--component-node-widget-background-highlighted: var(--color-smoke-800);
|
||||
--component-node-widget-promoted: var(--color-purple-700);
|
||||
--component-node-widget-advanced: var(--color-azure-600);
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -11,17 +11,39 @@
|
||||
:aria-label="$t('load3d.switchCamera')"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
|
||||
<i :class="cn('pi pi-camera text-lg text-base-foreground')" />
|
||||
</Button>
|
||||
<PopupSlider
|
||||
v-if="showFOVButton"
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.retainViewOnReload'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.retainViewOnReload')"
|
||||
:aria-pressed="retainViewOnReload"
|
||||
@click="retainViewOnReload = !retainViewOnReload"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-lg text-base-foreground',
|
||||
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
@@ -30,6 +52,9 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
|
||||
default: false
|
||||
})
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
|
||||
@@ -109,7 +109,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
|
||||
@@ -29,17 +29,47 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
te: vi.fn(() => false),
|
||||
st: vi.fn((_key: string, fallback: string) => fallback),
|
||||
t: vi.fn((key: string, params?: { count?: number }) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = params?.count ?? 0
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
return key
|
||||
})
|
||||
}))
|
||||
vi.mock('@/i18n', () => {
|
||||
const messages: Record<string, string> = {
|
||||
'errorCatalog.validationErrors.required_input_missing.title':
|
||||
'Missing connection',
|
||||
'errorCatalog.validationErrors.required_input_missing.message':
|
||||
'Required input slots have no connection feeding them.',
|
||||
'errorCatalog.validationErrors.required_input_missing.details':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.itemLabel':
|
||||
'{nodeName} - {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastTitle':
|
||||
'Required input missing',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastMessage':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
}
|
||||
|
||||
const interpolate = (
|
||||
message: string,
|
||||
params?: Record<string, string | number>
|
||||
) =>
|
||||
message.replace(/\{(\w+)\}/g, (match, paramName) =>
|
||||
params?.[paramName] === undefined ? match : String(params[paramName])
|
||||
)
|
||||
|
||||
return {
|
||||
te: vi.fn((key: string) => key in messages),
|
||||
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
|
||||
t: vi.fn((key: string, params?: Record<string, string | number>) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = Number(params?.count ?? 0)
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
|
||||
return interpolate(messages[key] ?? key, params)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => ({
|
||||
@@ -412,10 +442,16 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
if (execGroups[0].type !== 'execution') return
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
|
||||
'KSampler failed'
|
||||
)
|
||||
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
|
||||
message: 'RuntimeError: CUDA out of memory',
|
||||
details: 'line 1\nline 2',
|
||||
isRuntimeError: true,
|
||||
exceptionType: 'RuntimeError'
|
||||
})
|
||||
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
|
||||
// bypass catalog display fields until targeted runtime handling lands.
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes prompt error when present', async () => {
|
||||
@@ -428,7 +464,8 @@ describe('useErrorGroups', () => {
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -417,12 +417,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const resolvedDisplay = resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName: e.node_type,
|
||||
isCloud
|
||||
})
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
@@ -433,8 +427,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolvedDisplay
|
||||
exceptionType: e.exception_type
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
|
||||
@@ -5,9 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
@@ -71,7 +74,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
const NodeSearchContentStub = defineComponent({
|
||||
name: 'NodeSearchContent',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
filters: { type: Array, default: () => [] },
|
||||
defaultRootFilter: { type: String, default: null }
|
||||
},
|
||||
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
|
||||
setup(_, { emit }) {
|
||||
@@ -79,7 +83,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
emit('addNode', nodeDef, dragEvent)
|
||||
return {}
|
||||
},
|
||||
template: '<div data-testid="search-content-v2"></div>'
|
||||
template:
|
||||
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
@@ -276,4 +281,75 @@ describe('NodeSearchBoxPopover', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultRootFilter on dialog open', () => {
|
||||
function setGraphNodes(nodes: unknown[]) {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = {
|
||||
graph: { nodes },
|
||||
allow_searchbox: false,
|
||||
setDirty: vi.fn(),
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
reset: vi.fn(),
|
||||
disconnectLinks: vi.fn()
|
||||
}
|
||||
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
|
||||
}
|
||||
|
||||
async function openSearch() {
|
||||
useSearchBoxStore().visible = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('defaults to Essentials when the graph is empty', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to Essentials when the canvas is not yet available', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to null when the graph has nodes', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-evaluates each time the dialog opens', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
|
||||
useSearchBoxStore().visible = false
|
||||
await nextTick()
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<div v-if="useSearchBoxV2" role="search" class="relative">
|
||||
<NodeSearchContent
|
||||
:filters="nodeFilters"
|
||||
:default-root-filter="defaultRootFilter"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@@ -76,6 +77,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
@@ -87,6 +90,7 @@ let disconnectOnReset = false
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
|
||||
|
||||
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
|
||||
@@ -102,6 +106,13 @@ const enableNodePreview = computed(
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const defaultRootFilter = ref<RootCategoryId | null>(null)
|
||||
watch(visible, (isVisible) => {
|
||||
if (!isVisible) return
|
||||
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
|
||||
? RootCategory.Essentials
|
||||
: null
|
||||
})
|
||||
function getNewNodeLocation(): Point {
|
||||
return triggerEvent
|
||||
? [triggerEvent.canvasX, triggerEvent.canvasY]
|
||||
@@ -126,7 +137,6 @@ function clearFilters() {
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setViewport,
|
||||
@@ -230,6 +231,48 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply defaultRootFilter when provided and category is available', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
display_name: 'Essential Node',
|
||||
essentials_category: 'basic'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'FrequentNode',
|
||||
display_name: 'Frequent Node'
|
||||
})
|
||||
])
|
||||
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
|
||||
useNodeDefStore().nodeDefsByName['FrequentNode']
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show only API nodes when Partner Nodes filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
|
||||
@@ -141,8 +141,9 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
const { filters, defaultRootFilter = null } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
defaultRootFilter?: RootCategoryId | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -193,8 +194,12 @@ function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
const rootFilter = ref<RootCategoryId | null>(
|
||||
defaultRootFilter === RootCategory.Essentials &&
|
||||
!nodeAvailability.value.essential
|
||||
? null
|
||||
: defaultRootFilter
|
||||
)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('useLoad3d', () => {
|
||||
setMaterialMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setRetainViewOnReload: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -568,17 +569,21 @@ describe('useLoad3d', () => {
|
||||
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
|
||||
vi.mocked(mockLoad3d.setFOV!).mockClear()
|
||||
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
composable.cameraConfig.value.retainViewOnReload = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'orthographic',
|
||||
fov: 90,
|
||||
state: null
|
||||
state: null,
|
||||
retainViewOnReload: true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -483,6 +483,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
|
||||
@@ -2,7 +2,10 @@ import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
import type {
|
||||
CameraState,
|
||||
GizmoMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const {
|
||||
cloneSkinnedMock,
|
||||
@@ -769,6 +772,133 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('retainViewOnReload', () => {
|
||||
function setupLoadInternal(initialFlag: boolean) {
|
||||
const getCameraState = vi.fn<() => CameraState>(() => ({
|
||||
position: new THREE.Vector3(1, 2, 3),
|
||||
target: new THREE.Vector3(),
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
}))
|
||||
const setCameraState = vi.fn()
|
||||
const getCurrentCameraType = vi.fn(() => 'perspective' as const)
|
||||
const loaderLoadModel = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(ctx.load3d, {
|
||||
cameraManager: {
|
||||
...ctx.cameraManager,
|
||||
getCameraState,
|
||||
setCameraState,
|
||||
getCurrentCameraType
|
||||
},
|
||||
controlsManager: { ...ctx.controlsManager, reset: vi.fn() },
|
||||
loaderManager: { loadModel: loaderLoadModel },
|
||||
modelManager: {
|
||||
...ctx.modelManager,
|
||||
currentModel: new THREE.Group(),
|
||||
originalModel: null
|
||||
},
|
||||
animationManager: {
|
||||
...ctx.animationManager,
|
||||
setupModelAnimations: vi.fn()
|
||||
},
|
||||
handleResize: vi.fn(),
|
||||
retainViewOnReload: initialFlag,
|
||||
hasLoadedModel: false
|
||||
})
|
||||
return { getCameraState, setCameraState, getCurrentCameraType }
|
||||
}
|
||||
|
||||
it('first load uses default framing even with retain enabled', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
|
||||
// hasLoadedModel started false, so retain shouldn't kick in yet.
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subsequent load captures camera state, skips reset, and restores it', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
|
||||
expect(mocks.getCameraState).toHaveBeenCalledOnce()
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not retain when the flag is off, even after a prior load', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles to the saved camera type before restoring state when types differ', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
mocks.getCameraState.mockImplementation(() => ({
|
||||
position: new THREE.Vector3(0, 0, 5),
|
||||
target: new THREE.Vector3(),
|
||||
zoom: 1,
|
||||
cameraType: 'orthographic'
|
||||
}))
|
||||
// First load (active type stays perspective per the default mock).
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.toggleCamera as ReturnType<typeof vi.fn>).mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
|
||||
'orthographic'
|
||||
)
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.clearModel()
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.setRetainViewOnReload(true)
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
|
||||
expect(mocks.getCameraState).toHaveBeenCalledOnce()
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
it('hides the gizmo helper during capture and restores it after success', async () => {
|
||||
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
|
||||
|
||||
@@ -104,6 +104,8 @@ class Load3d {
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private retainViewOnReload: boolean = false
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
@@ -564,13 +566,33 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles whether `_loadModelInternal` preserves the current camera state
|
||||
* across model loads. When enabled and a model has previously loaded, the
|
||||
* camera position/target/zoom (and camera type) are captured before the
|
||||
* scene clears and restored after the new model is in place.
|
||||
*/
|
||||
public setRetainViewOnReload(value: boolean): void {
|
||||
this.retainViewOnReload = value
|
||||
}
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
this.cameraManager.reset()
|
||||
this.controlsManager.reset()
|
||||
// Retain view only kicks in after a successful first load — on the very
|
||||
// first load there's no meaningful "current" framing to preserve, so the
|
||||
// default `setupForModel` framing wins.
|
||||
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
|
||||
const savedCameraState = shouldRetainView
|
||||
? this.cameraManager.getCameraState()
|
||||
: null
|
||||
|
||||
if (!shouldRetainView) {
|
||||
this.cameraManager.reset()
|
||||
this.controlsManager.reset()
|
||||
}
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.animationManager.dispose()
|
||||
@@ -583,6 +605,19 @@ class Load3d {
|
||||
this.modelManager.currentModel,
|
||||
this.modelManager.originalModel
|
||||
)
|
||||
this.hasLoadedModel = true
|
||||
}
|
||||
|
||||
if (savedCameraState) {
|
||||
// SceneModelManager.setupModel called setupForModel which clobbered the
|
||||
// camera. Restore the captured state on top of that.
|
||||
if (
|
||||
savedCameraState.cameraType !==
|
||||
this.cameraManager.getCurrentCameraType()
|
||||
) {
|
||||
this.toggleCamera(savedCameraState.cameraType)
|
||||
}
|
||||
this.cameraManager.setCameraState(savedCameraState)
|
||||
}
|
||||
|
||||
this.handleResize()
|
||||
@@ -607,6 +642,7 @@ class Load3d {
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.adapterRef.current = null
|
||||
this.hasLoadedModel = false
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
retainViewOnReload?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "تم الإيقاف مؤقتًا",
|
||||
"resume": "استئناف التنزيل"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "مدخل غير معروف",
|
||||
"nodeName": "هذه العقدة"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة أو معاينة الصورة) لإنتاج نتيجة."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "واجه الخادم خطأ غير متوقع. يرجى المحاولة لاحقاً."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "واجه الخادم خطأ غير متوقع. يرجى مراجعة سجلات الخادم."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "حدث خطأ أثناء تنفيذ هذه العقدة. تحقق من المدخلات أو جرّب إعداداً مختلفاً. لم يتم خصم أي رصيد.",
|
||||
"toastMessageLocal": "حدث خطأ أثناء تنفيذ هذه العقدة. تحقق من المدخلات أو جرّب إعداداً مختلفاً.",
|
||||
"toastTitle": "فشل {nodeName}"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} تفتقد إلى مدخل مطلوب: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "بعض منافذ الإدخال المطلوبة غير متصلة.",
|
||||
"title": "الاتصال مفقود",
|
||||
"toastMessage": "{nodeName} تفتقد إلى مدخل مطلوب: {inputName}",
|
||||
"toastTitle": "مدخل مطلوب مفقود"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "جاري إعادة تحميل النموذج...",
|
||||
"removeBackgroundImage": "إزالة صورة الخلفية",
|
||||
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
|
||||
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
|
||||
"scene": "المشهد",
|
||||
"showGrid": "عرض الشبكة",
|
||||
"showSkeleton": "إظهار الهيكل العظمي",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "عادي",
|
||||
"parameters": "المعلمات",
|
||||
"pinned": "مثبت",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة، معاينة الصورة) لإنتاج نتيجة."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "واجه الخادم خطأ غير متوقع. يرجى المحاولة لاحقاً."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "واجه الخادم خطأ غير متوقع. يرجى مراجعة سجلات الخادم."
|
||||
}
|
||||
},
|
||||
"properties": "الخصائص",
|
||||
"removeFavorite": "إزالة من المفضلة",
|
||||
"resetAllParameters": "إعادة تعيين جميع المعلمات",
|
||||
|
||||
@@ -1952,6 +1952,7 @@
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"retainViewOnReload": "Lock camera view across model reloads",
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
@@ -3667,28 +3668,145 @@
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Required input missing",
|
||||
"toastMessage": "{nodeName} is missing a required input: {inputName}"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
},
|
||||
"bad_linked_input": {
|
||||
"title": "Invalid connection",
|
||||
"message": "A node connection could not be read correctly.",
|
||||
"details": "{nodeName} has an invalid connection for {inputName}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid connection",
|
||||
"toastMessage": "{nodeName} has an invalid connection for {inputName}."
|
||||
},
|
||||
"return_type_mismatch": {
|
||||
"title": "Invalid connection",
|
||||
"message": "Connected nodes are using incompatible input and output types.",
|
||||
"details": "{nodeName} has an incompatible connection for {inputName}.",
|
||||
"detailsWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid connection",
|
||||
"toastMessage": "{nodeName} has an incompatible connection for {inputName}.",
|
||||
"toastMessageWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}."
|
||||
},
|
||||
"invalid_input_type": {
|
||||
"title": "Invalid input",
|
||||
"message": "An input value has the wrong type.",
|
||||
"details": "{nodeName} couldn't convert {inputName} to the expected type.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} couldn't convert {inputName} to the expected type.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}."
|
||||
},
|
||||
"value_smaller_than_min": {
|
||||
"title": "Input out of range",
|
||||
"message": "Some input values are outside the allowed range.",
|
||||
"details": "{nodeName} has a value below the minimum for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Input out of range",
|
||||
"toastMessage": "{nodeName} has a value below the minimum for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}."
|
||||
},
|
||||
"value_bigger_than_max": {
|
||||
"title": "Input out of range",
|
||||
"message": "Some input values are outside the allowed range.",
|
||||
"details": "{nodeName} has a value above the maximum for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Input out of range",
|
||||
"toastMessage": "{nodeName} has a value above the maximum for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}."
|
||||
},
|
||||
"value_not_in_list": {
|
||||
"title": "Invalid input",
|
||||
"message": "Some input values are not available for this node.",
|
||||
"details": "{nodeName} has an unsupported value for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} has an unsupported value for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available."
|
||||
},
|
||||
"custom_validation_failed": {
|
||||
"title": "Invalid input",
|
||||
"message": "A node rejected one or more input values.",
|
||||
"details": "{nodeName} rejected the value for {inputName}.",
|
||||
"detailsWithRawDetails": "{nodeName} failed custom validation: {rawDetails}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} rejected the value for {inputName}."
|
||||
},
|
||||
"exception_during_inner_validation": {
|
||||
"title": "Validation failed",
|
||||
"message": "The workflow couldn't validate a connected node.",
|
||||
"details": "{nodeName} couldn't validate {inputName}.",
|
||||
"detailsWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Validation failed",
|
||||
"toastMessage": "{nodeName} couldn't validate {inputName}.",
|
||||
"toastMessageWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}"
|
||||
},
|
||||
"exception_during_validation": {
|
||||
"title": "Validation failed",
|
||||
"message": "The workflow could not be validated because a node validation check failed unexpectedly.",
|
||||
"details": "{nodeName} failed during validation.",
|
||||
"detailsWithRawDetails": "{nodeName} failed during validation: {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "{nodeName} failed",
|
||||
"toastMessageLocal": "This node threw an error during execution. Check its inputs or try a different configuration.",
|
||||
"toastMessageCloud": "This node threw an error during execution. Check its inputs or try a different configuration. No credits charged."
|
||||
"toastTitle": "Validation failed",
|
||||
"toastMessage": "{nodeName} failed during validation.",
|
||||
"toastMessageWithRawDetails": "{nodeName} failed during validation: {rawDetails}"
|
||||
},
|
||||
"dependency_cycle": {
|
||||
"title": "Invalid workflow",
|
||||
"message": "The workflow has a circular node connection.",
|
||||
"details": "{nodeName} is part of a circular connection.",
|
||||
"detailsWithRawDetails": "{nodeName} is part of a circular connection: {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "Invalid workflow",
|
||||
"toastMessage": "{nodeName} is part of a circular connection."
|
||||
},
|
||||
"image_not_loaded": {
|
||||
"title": "Image not loaded",
|
||||
"message": "The system couldn't load this image.",
|
||||
"details": "The image for {nodeName} couldn't be loaded. Try adding it again.",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "Input image couldn't be loaded",
|
||||
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
|
||||
}
|
||||
},
|
||||
"promptErrors": {
|
||||
"prompt_no_outputs": {
|
||||
"title": "Prompt has no outputs",
|
||||
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
|
||||
},
|
||||
"no_prompt": {
|
||||
"title": "Workflow data is empty",
|
||||
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
|
||||
},
|
||||
"server_error_local": {
|
||||
"title": "Server error",
|
||||
"desc": "The server encountered an unexpected error. Please check the server logs."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"title": "Server error",
|
||||
"desc": "The server encountered an unexpected error. Please try again later."
|
||||
},
|
||||
"missing_node_type": {
|
||||
"title": "Missing node type",
|
||||
"desc": "A node type is missing or unavailable. The workflow may be corrupted or require a custom node."
|
||||
},
|
||||
"prompt_outputs_failed_validation": {
|
||||
"title": "Prompt validation failed",
|
||||
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
|
||||
},
|
||||
"image_not_loaded": {
|
||||
"title": "Image not loaded",
|
||||
"desc": "The system couldn't load this image."
|
||||
},
|
||||
"out_of_memory": {
|
||||
"title": "Generation failed",
|
||||
"descLocal": "Not enough GPU memory. Try reducing complexity and run again.",
|
||||
"descCloud": "Not enough GPU memory. Try reducing complexity and run again. No credits charged."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "Pausado",
|
||||
"resume": "Reanudar descarga"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "entrada desconocida",
|
||||
"nodeName": "Este nodo"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Los datos del flujo de trabajo enviados al servidor están vacíos. Esto puede ser un error inesperado del sistema."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "El flujo de trabajo no contiene ningún nodo de salida (por ejemplo, Guardar imagen, Vista previa de imagen) para producir un resultado."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "El servidor encontró un error inesperado. Por favor, inténtalo de nuevo más tarde."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "El servidor encontró un error inesperado. Por favor, revisa los registros del servidor."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Este nodo generó un error durante la ejecución. Revisa sus entradas o prueba una configuración diferente. No se cobraron créditos.",
|
||||
"toastMessageLocal": "Este nodo generó un error durante la ejecución. Revisa sus entradas o prueba una configuración diferente.",
|
||||
"toastTitle": "{nodeName} falló"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} carece de una entrada requerida: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Las ranuras de entrada requeridas no tienen ninguna conexión.",
|
||||
"title": "Conexión faltante",
|
||||
"toastMessage": "{nodeName} carece de una entrada requerida: {inputName}",
|
||||
"toastTitle": "Falta entrada requerida"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Recargando modelo...",
|
||||
"removeBackgroundImage": "Eliminar imagen de fondo",
|
||||
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
|
||||
"retainViewOnReload": "Bloquear la vista de la cámara al recargar el modelo",
|
||||
"scene": "Escena",
|
||||
"showGrid": "Mostrar cuadrícula",
|
||||
"showSkeleton": "Mostrar esqueleto",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Normal",
|
||||
"parameters": "Parámetros",
|
||||
"pinned": "Fijado",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Los datos del flujo de trabajo enviados al servidor están vacíos. Esto puede ser un error inesperado del sistema."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "El flujo de trabajo no contiene ningún nodo de salida (por ejemplo, Guardar imagen, Vista previa de imagen) para producir un resultado."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "El servidor encontró un error inesperado. Por favor, inténtalo de nuevo más tarde."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "El servidor encontró un error inesperado. Por favor, revisa los registros del servidor."
|
||||
}
|
||||
},
|
||||
"properties": "Propiedades",
|
||||
"removeFavorite": "Quitar de favoritos",
|
||||
"resetAllParameters": "Restablecer todos los parámetros",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "متوقف شده",
|
||||
"resume": "ادامه دانلود"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "ورودی نامشخص",
|
||||
"nodeName": "این نود"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "دادههای ورکفلو ارسالشده به سرور خالی است. این ممکن است یک خطای غیرمنتظره سیستمی باشد."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "در این ورکفلو هیچ نود خروجی (مانند Save Image یا Preview Image) برای تولید نتیجه وجود ندارد."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "سرور با یک خطای غیرمنتظره مواجه شد. لطفاً بعداً دوباره تلاش کنید."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "سرور با یک خطای غیرمنتظره مواجه شد. لطفاً لاگهای سرور را بررسی کنید."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "این نود هنگام اجرا با خطا مواجه شد. ورودیها را بررسی کنید یا پیکربندی دیگری را امتحان نمایید. هیچ اعتباری کسر نشد.",
|
||||
"toastMessageLocal": "این نود هنگام اجرا با خطا مواجه شد. ورودیها را بررسی کنید یا پیکربندی دیگری را امتحان نمایید.",
|
||||
"toastTitle": "{nodeName} با خطا مواجه شد"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} یک ورودی ضروری را ندارد: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "ورودیهای ضروری بدون اتصال هستند.",
|
||||
"title": "اتصال وجود ندارد",
|
||||
"toastMessage": "{nodeName} یک ورودی ضروری را ندارد: {inputName}",
|
||||
"toastTitle": "ورودی ضروری وجود ندارد"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "در حال بارگذاری مجدد مدل...",
|
||||
"removeBackgroundImage": "حذف تصویر پسزمینه",
|
||||
"resizeNodeMatchOutput": "تغییر اندازه node مطابق خروجی",
|
||||
"retainViewOnReload": "قفل کردن نمای دوربین هنگام بارگذاری مجدد مدل",
|
||||
"scene": "صحنه",
|
||||
"showGrid": "نمایش شبکه",
|
||||
"showSkeleton": "نمایش اسکلت",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "عادی",
|
||||
"parameters": "پارامترها",
|
||||
"pinned": "سنجاق شده",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "دادههای گردشکار ارسالشده به سرور خالی است. این ممکن است یک خطای غیرمنتظره سیستمی باشد."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "گردشکار هیچ نود خروجی (مانند Save Image یا Preview Image) برای تولید نتیجه ندارد."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "سرور با خطای غیرمنتظرهای مواجه شد. لطفاً بعداً دوباره تلاش کنید."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "سرور با خطای غیرمنتظرهای مواجه شد. لطفاً لاگهای سرور را بررسی کنید."
|
||||
}
|
||||
},
|
||||
"properties": "ویژگیها",
|
||||
"removeFavorite": "حذف از علاقهمندیها",
|
||||
"resetAllParameters": "بازنشانی همه پارامترها",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "En pause",
|
||||
"resume": "Reprendre le téléchargement"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "entrée inconnue",
|
||||
"nodeName": "Ce nœud"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Les données du workflow envoyées au serveur sont vides. Il peut s’agir d’une erreur système inattendue."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Le workflow ne contient aucun nœud de sortie (par exemple, Enregistrer l’image, Prévisualiser l’image) pour produire un résultat."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez réessayer plus tard."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez consulter les journaux du serveur."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Ce nœud a généré une erreur lors de l’exécution. Vérifiez ses entrées ou essayez une autre configuration. Aucun crédit n’a été déduit.",
|
||||
"toastMessageLocal": "Ce nœud a généré une erreur lors de l’exécution. Vérifiez ses entrées ou essayez une autre configuration.",
|
||||
"toastTitle": "Échec de {nodeName}"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} nécessite une entrée obligatoire : {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Des entrées requises ne sont pas connectées.",
|
||||
"title": "Connexion manquante",
|
||||
"toastMessage": "{nodeName} nécessite une entrée obligatoire : {inputName}",
|
||||
"toastTitle": "Entrée requise manquante"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Rechargement du modèle...",
|
||||
"removeBackgroundImage": "Supprimer l'image de fond",
|
||||
"resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie",
|
||||
"retainViewOnReload": "Verrouiller la vue de la caméra lors du rechargement du modèle",
|
||||
"scene": "Scène",
|
||||
"showGrid": "Afficher la grille",
|
||||
"showSkeleton": "Afficher le squelette",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Normal",
|
||||
"parameters": "Paramètres",
|
||||
"pinned": "Épinglé",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Les données du flux de travail envoyées au serveur sont vides. Il peut s'agir d'une erreur système inattendue."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Le flux de travail ne contient aucun nœud de sortie (par exemple, Enregistrer l'image, Prévisualiser l'image) pour produire un résultat."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez réessayer plus tard."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez consulter les journaux du serveur."
|
||||
}
|
||||
},
|
||||
"properties": "Propriétés",
|
||||
"removeFavorite": "Retirer des favoris",
|
||||
"resetAllParameters": "Réinitialiser tous les paramètres",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "一時停止",
|
||||
"resume": "ダウンロードを再開"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "不明な入力",
|
||||
"nodeName": "このノード"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "サーバーに送信されたワークフローデータが空です。これは予期しないシステムエラーの可能性があります。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "ワークフローに出力ノード(例:画像を保存、画像をプレビュー)が含まれていないため、結果を生成できません。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "サーバーで予期しないエラーが発生しました。しばらくしてから再度お試しください。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "サーバーで予期しないエラーが発生しました。サーバーログを確認してください。"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "このノードの実行中にエラーが発生しました。入力内容を確認するか、別の設定をお試しください。クレジットは消費されていません。",
|
||||
"toastMessageLocal": "このノードの実行中にエラーが発生しました。入力内容を確認するか、別の設定をお試しください。",
|
||||
"toastTitle": "{nodeName} の実行に失敗しました"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} に必須入力 {inputName} がありません。",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "必須入力スロットに接続がありません。",
|
||||
"title": "接続がありません",
|
||||
"toastMessage": "{nodeName} に必須入力 {inputName} がありません。",
|
||||
"toastTitle": "必須入力がありません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "モデルを再読み込み中...",
|
||||
"removeBackgroundImage": "背景画像を削除",
|
||||
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
|
||||
"retainViewOnReload": "モデルの再読み込み時にカメラビューを固定する",
|
||||
"scene": "シーン",
|
||||
"showGrid": "グリッドを表示",
|
||||
"showSkeleton": "スケルトンを表示",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "ノーマル",
|
||||
"parameters": "パラメータ",
|
||||
"pinned": "ピン留め",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "サーバーに送信されたワークフローデータが空です。これは予期しないシステムエラーの可能性があります。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "ワークフローに結果を生成する出力ノード(例:画像を保存、画像をプレビュー)が含まれていません。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "サーバーで予期しないエラーが発生しました。しばらくしてから再度お試しください。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "サーバーで予期しないエラーが発生しました。サーバーログをご確認ください。"
|
||||
}
|
||||
},
|
||||
"properties": "プロパティ",
|
||||
"removeFavorite": "お気に入りを解除",
|
||||
"resetAllParameters": "すべてのパラメータをリセット",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "일시 중지됨",
|
||||
"resume": "다운로드 재개"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "알 수 없는 입력",
|
||||
"nodeName": "이 노드"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "서버로 전송된 워크플로우 데이터가 비어 있습니다. 시스템 오류일 수 있습니다."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "워크플로우에 결과를 생성할 출력 노드(예: 이미지 저장, 이미지 미리보기)가 없습니다."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 서버 로그를 확인해 주세요."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "이 노드는 실행 중 오류가 발생했습니다. 입력값을 확인하거나 다른 설정을 시도해 보세요. 크레딧은 차감되지 않았습니다.",
|
||||
"toastMessageLocal": "이 노드는 실행 중 오류가 발생했습니다. 입력값을 확인하거나 다른 설정을 시도해 보세요.",
|
||||
"toastTitle": "{nodeName} 실패"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName}에 필수 입력이 누락되었습니다: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "필수 입력 슬롯에 연결이 없습니다.",
|
||||
"title": "연결 누락",
|
||||
"toastMessage": "{nodeName}에 필수 입력이 누락되었습니다: {inputName}",
|
||||
"toastTitle": "필수 입력 누락"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "모델 다시 로드 중...",
|
||||
"removeBackgroundImage": "배경 이미지 제거",
|
||||
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
|
||||
"retainViewOnReload": "모델을 다시 불러와도 카메라 뷰 고정",
|
||||
"scene": "장면",
|
||||
"showGrid": "그리드 표시",
|
||||
"showSkeleton": "스켈레톤 표시",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "일반",
|
||||
"parameters": "파라미터",
|
||||
"pinned": "고정됨",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "서버로 전송된 워크플로우 데이터가 비어 있습니다. 이는 예기치 않은 시스템 오류일 수 있습니다."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "워크플로우에 결과를 생성할 출력 노드(예: 이미지 저장, 이미지 미리보기)가 포함되어 있지 않습니다."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 나중에 다시 시도해 주세요."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 서버 로그를 확인해 주세요."
|
||||
}
|
||||
},
|
||||
"properties": "속성",
|
||||
"removeFavorite": "즐겨찾기 해제",
|
||||
"resetAllParameters": "모든 매개변수 재설정",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "Pausado",
|
||||
"resume": "Retomar download"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "entrada desconhecida",
|
||||
"nodeName": "Este nó"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Os dados do fluxo de trabalho enviados ao servidor estão vazios. Isso pode ser um erro inesperado do sistema."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "O fluxo de trabalho não contém nenhum nó de saída (ex: Salvar Imagem, Visualizar Imagem) para produzir um resultado."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "O servidor encontrou um erro inesperado. Por favor, tente novamente mais tarde."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "O servidor encontrou um erro inesperado. Por favor, verifique os logs do servidor."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Este nó gerou um erro durante a execução. Verifique suas entradas ou tente uma configuração diferente. Nenhum crédito foi cobrado.",
|
||||
"toastMessageLocal": "Este nó gerou um erro durante a execução. Verifique suas entradas ou tente uma configuração diferente.",
|
||||
"toastTitle": "{nodeName} falhou"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} está sem uma entrada obrigatória: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Entradas obrigatórias não possuem conexão.",
|
||||
"title": "Conexão ausente",
|
||||
"toastMessage": "{nodeName} está sem uma entrada obrigatória: {inputName}",
|
||||
"toastTitle": "Entrada obrigatória ausente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Recarregando modelo...",
|
||||
"removeBackgroundImage": "Remover Imagem de Fundo",
|
||||
"resizeNodeMatchOutput": "Redimensionar Nó para corresponder à saída",
|
||||
"retainViewOnReload": "Manter a visão da câmera ao recarregar o modelo",
|
||||
"scene": "Cena",
|
||||
"showGrid": "Mostrar Grade",
|
||||
"showSkeleton": "Mostrar Esqueleto",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Normal",
|
||||
"parameters": "Parâmetros",
|
||||
"pinned": "Fixado",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Os dados do fluxo de trabalho enviados ao servidor estão vazios. Isso pode ser um erro de sistema inesperado."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "O fluxo de trabalho não contém nenhum nó de saída (por exemplo, Salvar Imagem, Visualizar Imagem) para produzir um resultado."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "O servidor encontrou um erro inesperado. Por favor, tente novamente mais tarde."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "O servidor encontrou um erro inesperado. Por favor, verifique os logs do servidor."
|
||||
}
|
||||
},
|
||||
"properties": "Propriedades",
|
||||
"removeFavorite": "Desfavoritar",
|
||||
"resetAllParameters": "Redefinir todos os parâmetros",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "Приостановлено",
|
||||
"resume": "Возобновить загрузку"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "неизвестный вход",
|
||||
"nodeName": "Этот узел"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Данные рабочего процесса, отправленные на сервер, пусты. Это может быть неожиданной системной ошибкой."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "В рабочем процессе отсутствуют выходные узлы (например, Сохранить изображение, Просмотр изображения) для получения результата."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, попробуйте позже."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, проверьте журналы сервера."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Этот узел вызвал ошибку во время выполнения. Проверьте его входные данные или попробуйте другую конфигурацию. Кредиты не списаны.",
|
||||
"toastMessageLocal": "Этот узел вызвал ошибку во время выполнения. Проверьте его входные данные или попробуйте другую конфигурацию.",
|
||||
"toastTitle": "{nodeName} завершился с ошибкой"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} отсутствует обязательный вход: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Требуемые входные слоты не имеют подключений.",
|
||||
"title": "Отсутствует соединение",
|
||||
"toastMessage": "{nodeName} отсутствует обязательный вход: {inputName}",
|
||||
"toastTitle": "Отсутствует обязательный вход"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Перезагрузка модели...",
|
||||
"removeBackgroundImage": "Удалить фоновое изображение",
|
||||
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
|
||||
"retainViewOnReload": "Зафиксировать вид камеры при перезагрузке модели",
|
||||
"scene": "Сцена",
|
||||
"showGrid": "Показать сетку",
|
||||
"showSkeleton": "Показать скелет",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Обычный",
|
||||
"parameters": "Параметры",
|
||||
"pinned": "Закреплено",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Данные рабочего процесса, отправленные на сервер, пусты. Это может быть неожиданной системной ошибкой."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "В рабочем процессе отсутствуют выходные узлы (например, Сохранить изображение, Предпросмотр изображения) для получения результата."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, попробуйте позже."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, проверьте логи сервера."
|
||||
}
|
||||
},
|
||||
"properties": "Свойства",
|
||||
"removeFavorite": "Убрать из избранного",
|
||||
"resetAllParameters": "Сбросить все параметры",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "Duraklatıldı",
|
||||
"resume": "İndirmeye Devam Et"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "bilinmeyen giriş",
|
||||
"nodeName": "Bu düğüm"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Sunucuya gönderilen çalışma akışı verisi boş. Bu beklenmeyen bir sistem hatası olabilir."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Çalışma akışında sonuç üretecek herhangi bir çıktı düğümü (örn. Görseli Kaydet, Görseli Önizle) bulunmuyor."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "Sunucuda beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "Sunucuda beklenmeyen bir hata oluştu. Lütfen sunucu günlüklerini kontrol edin."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Bu düğüm çalıştırılırken bir hata oluştu. Girişlerini kontrol edin veya farklı bir yapılandırmayı deneyin. Kredi harcanmadı.",
|
||||
"toastMessageLocal": "Bu düğüm çalıştırılırken bir hata oluştu. Girişlerini kontrol edin veya farklı bir yapılandırmayı deneyin.",
|
||||
"toastTitle": "{nodeName} başarısız oldu"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} için gerekli bir giriş eksik: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Gerekli giriş yuvalarına bağlantı yapılmamış.",
|
||||
"title": "Eksik bağlantı",
|
||||
"toastMessage": "{nodeName} için gerekli bir giriş eksik: {inputName}",
|
||||
"toastTitle": "Gerekli giriş eksik"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Model yeniden yükleniyor...",
|
||||
"removeBackgroundImage": "Arka Plan Resmini Kaldır",
|
||||
"resizeNodeMatchOutput": "Düğümü çıktıya uyacak şekilde yeniden boyutlandır",
|
||||
"retainViewOnReload": "Model yeniden yüklendiğinde kamera görünümünü kilitle",
|
||||
"scene": "Sahne",
|
||||
"showGrid": "Izgarayı Göster",
|
||||
"showSkeleton": "İskeleti Göster",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Normal",
|
||||
"parameters": "Parametreler",
|
||||
"pinned": "Sabitlendi",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Sunucuya gönderilen çalışma akışı verisi boş. Bu beklenmeyen bir sistem hatası olabilir."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Çalışma akışında sonuç üretecek herhangi bir çıktı düğümü (ör. Görüntüyü Kaydet, Görüntüyü Önizle) bulunmuyor."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "Sunucu beklenmeyen bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "Sunucu beklenmeyen bir hata ile karşılaştı. Lütfen sunucu günlüklerini kontrol edin."
|
||||
}
|
||||
},
|
||||
"properties": "Özellikler",
|
||||
"removeFavorite": "Favorilerden Kaldır",
|
||||
"resetAllParameters": "Tüm parametreleri sıfırla",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "已暫停",
|
||||
"resume": "繼續下載"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "未知輸入",
|
||||
"nodeName": "此節點"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "傳送到伺服器的工作流程資料為空。這可能是系統的非預期錯誤。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "工作流程中沒有包含任何輸出節點(例如:儲存圖像、預覽圖像),無法產生結果。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "伺服器發生非預期錯誤。請稍後再試。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "伺服器發生非預期錯誤。請檢查伺服器日誌。"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "此節點在執行時發生錯誤。請檢查其輸入或嘗試其他設定。未扣除點數。",
|
||||
"toastMessageLocal": "此節點在執行時發生錯誤。請檢查其輸入或嘗試其他設定。",
|
||||
"toastTitle": "{nodeName} 執行失敗"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} 缺少必要的輸入:{inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "必要的輸入插槽沒有連接來源。",
|
||||
"title": "缺少連接",
|
||||
"toastMessage": "{nodeName} 缺少必要的輸入:{inputName}",
|
||||
"toastTitle": "缺少必要輸入"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "您的帳戶無權使用此功能。",
|
||||
"accessRestrictedTitle": "存取受限",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "重新載入模型中...",
|
||||
"removeBackgroundImage": "移除背景圖片",
|
||||
"resizeNodeMatchOutput": "調整節點以符合輸出",
|
||||
"retainViewOnReload": "鎖定相機視角於模型重新載入時保持不變",
|
||||
"scene": "場景",
|
||||
"showGrid": "顯示格線",
|
||||
"showSkeleton": "顯示骨架",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "一般",
|
||||
"parameters": "參數",
|
||||
"pinned": "已釘選",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "傳送到伺服器的工作流程資料為空。這可能是系統發生了非預期的錯誤。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "工作流程中沒有任何輸出節點(例如:儲存圖像、預覽圖像),無法產生結果。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "伺服器發生非預期錯誤。請稍後再試。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "伺服器發生非預期錯誤。請檢查伺服器日誌。"
|
||||
}
|
||||
},
|
||||
"properties": "屬性",
|
||||
"removeFavorite": "取消收藏",
|
||||
"resetAllParameters": "重設所有參數",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "已暂停",
|
||||
"resume": "恢复下载"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "未知输入",
|
||||
"nodeName": "此节点"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "发送到服务器的工作流数据为空。这可能是系统的意外错误。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "工作流未包含任何输出节点(如保存图像、预览图像),无法生成结果。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "服务器遇到意外错误。请稍后再试。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "服务器遇到意外错误。请检查服务器日志。"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "此节点在执行过程中发生错误。请检查其输入或尝试其他配置。未扣除积分。",
|
||||
"toastMessageLocal": "此节点在执行过程中发生错误。请检查其输入或尝试其他配置。",
|
||||
"toastTitle": "{nodeName} 执行失败"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} 缺少必需的输入:{inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "必需的输入插槽没有连接。",
|
||||
"title": "缺少连接",
|
||||
"toastMessage": "{nodeName} 缺少必需的输入:{inputName}",
|
||||
"toastTitle": "缺少必需输入"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "您的账户无权使用此功能。",
|
||||
"accessRestrictedTitle": "访问受限",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "正在重新加载模型...",
|
||||
"removeBackgroundImage": "移除背景图片",
|
||||
"resizeNodeMatchOutput": "调整节点以匹配输出",
|
||||
"retainViewOnReload": "模型重新加载时锁定相机视角",
|
||||
"scene": "场景",
|
||||
"showGrid": "显示网格",
|
||||
"showSkeleton": "显示骨架",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "正常",
|
||||
"parameters": "参数",
|
||||
"pinned": "顶固",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "发送到服务器的工作流数据为空。这可能是一个意外的系统错误。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "工作流中没有包含任何输出节点(例如:保存图像、预览图像),无法生成结果。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "服务器遇到意外错误。请稍后再试。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "服务器遇到意外错误。请检查服务器日志。"
|
||||
}
|
||||
},
|
||||
"properties": "属性",
|
||||
"removeFavorite": "取消收藏",
|
||||
"resetAllParameters": "重置所有参数",
|
||||
|
||||
@@ -7,31 +7,32 @@ import {
|
||||
import type { NodeValidationError } from './types'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
function requiredInputMissing(inputName?: string): NodeValidationError {
|
||||
return {
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: inputName ?? '',
|
||||
extra_info: inputName
|
||||
function nodeValidationError(
|
||||
type: string,
|
||||
inputName?: string,
|
||||
details = inputName ?? '',
|
||||
extraInfo: Record<string, unknown> = {}
|
||||
): NodeValidationError {
|
||||
const extra_info =
|
||||
inputName || Object.keys(extraInfo).length > 0
|
||||
? {
|
||||
input_name: inputName
|
||||
...(inputName ? { input_name: inputName } : {}),
|
||||
...extraInfo
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
type,
|
||||
message: 'Validation failed',
|
||||
details,
|
||||
extra_info
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeError() {
|
||||
function requiredInputMissing(inputName?: string): NodeValidationError {
|
||||
return {
|
||||
prompt_id: 'test',
|
||||
timestamp: Date.now(),
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'CUDA out of memory',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
...nodeValidationError('required_input_missing', inputName),
|
||||
message: 'Required input is missing'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('interpolates fallback templates when catalog keys are missing in the active locale', () => {
|
||||
it('falls back to raw API copy when catalog keys are missing in the active locale', () => {
|
||||
const originalLocale = i18n.global.locale.value
|
||||
const originalKoMessages = i18n.global.getLocaleMessage('ko')
|
||||
|
||||
@@ -83,9 +84,12 @@ describe('errorMessageResolver', () => {
|
||||
nodeDisplayName: '0'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: '0 is missing a required input: seed',
|
||||
displayTitle: 'Required input is missing',
|
||||
displayMessage: 'Required input is missing',
|
||||
displayDetails: 'seed',
|
||||
displayItemLabel: '0 - seed',
|
||||
toastMessage: '0 is missing a required input: seed'
|
||||
toastTitle: 'Required input is missing',
|
||||
toastMessage: 'Required input is missing'
|
||||
})
|
||||
} finally {
|
||||
i18n.global.setLocaleMessage('ko', originalKoMessages)
|
||||
@@ -93,34 +97,348 @@ describe('errorMessageResolver', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves runtime errors with item labels and toast copy', () => {
|
||||
it.for([
|
||||
{
|
||||
type: 'bad_linked_input',
|
||||
inputName: 'model',
|
||||
expected: {
|
||||
catalogId: 'bad_linked_input',
|
||||
displayTitle: 'Invalid connection',
|
||||
displayMessage: 'A node connection could not be read correctly.',
|
||||
displayDetails: 'KSampler has an invalid connection for model.',
|
||||
displayItemLabel: 'KSampler - model',
|
||||
toastTitle: 'Invalid connection',
|
||||
toastMessage: 'KSampler has an invalid connection for model.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value_not_in_list',
|
||||
inputName: 'scheduler',
|
||||
expected: {
|
||||
catalogId: 'value_not_in_list',
|
||||
displayTitle: 'Invalid input',
|
||||
displayMessage: 'Some input values are not available for this node.',
|
||||
displayDetails: 'KSampler has an unsupported value for scheduler.',
|
||||
displayItemLabel: 'KSampler - scheduler',
|
||||
toastTitle: 'Invalid input',
|
||||
toastMessage: 'KSampler has an unsupported value for scheduler.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value_smaller_than_min',
|
||||
inputName: 'steps',
|
||||
expected: {
|
||||
catalogId: 'value_smaller_than_min',
|
||||
displayTitle: 'Input out of range',
|
||||
displayMessage: 'Some input values are outside the allowed range.',
|
||||
displayDetails: 'KSampler has a value below the minimum for steps.',
|
||||
displayItemLabel: 'KSampler - steps',
|
||||
toastTitle: 'Input out of range',
|
||||
toastMessage: 'KSampler has a value below the minimum for steps.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'return_type_mismatch',
|
||||
inputName: 'model',
|
||||
expected: {
|
||||
catalogId: 'return_type_mismatch',
|
||||
displayTitle: 'Invalid connection',
|
||||
displayMessage:
|
||||
'Connected nodes are using incompatible input and output types.',
|
||||
displayDetails: 'KSampler has an incompatible connection for model.',
|
||||
displayItemLabel: 'KSampler - model',
|
||||
toastTitle: 'Invalid connection',
|
||||
toastMessage: 'KSampler has an incompatible connection for model.'
|
||||
}
|
||||
}
|
||||
])('resolves $type validation errors', ({ type, inputName, expected }) => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
isCloud: true,
|
||||
nodeDisplayName: 'KSampler',
|
||||
error: runtimeError()
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(type, inputName),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'execution_failed',
|
||||
displayItemLabel: 'KSampler',
|
||||
toastTitle: 'KSampler failed',
|
||||
).toEqual(expected)
|
||||
})
|
||||
|
||||
it('includes received values in validation range and option details', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'return_type_mismatch',
|
||||
'images',
|
||||
'images, received_type(LATENT) mismatch input_type(IMAGE)',
|
||||
{
|
||||
input_config: ['IMAGE', {}],
|
||||
received_type: 'LATENT'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"Preview Image's images input expects IMAGE, but the connected output is LATENT.",
|
||||
toastMessage:
|
||||
'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
|
||||
"Preview Image's images input expects IMAGE, but the connected output is LATENT."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'invalid_input_type',
|
||||
'steps',
|
||||
"steps, abc, invalid literal for int() with base 10: 'abc'",
|
||||
{
|
||||
input_config: ['INT', {}],
|
||||
received_value: 'abc'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value abc for KSampler's steps couldn't be converted to INT.",
|
||||
toastMessage:
|
||||
"The value abc for KSampler's steps couldn't be converted to INT."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError('value_smaller_than_min', 'steps', 'steps', {
|
||||
input_config: ['INT', { min: 1 }],
|
||||
received_value: 0
|
||||
}),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value 0 for KSampler's steps is below the minimum 1.",
|
||||
toastMessage: "The value 0 for KSampler's steps is below the minimum 1."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError('value_bigger_than_max', 'cfg', 'cfg', {
|
||||
input_config: ['FLOAT', { max: 30 }],
|
||||
received_value: 40
|
||||
}),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value 40 for KSampler's cfg is above the maximum 30.",
|
||||
toastMessage: "The value 40 for KSampler's cfg is above the maximum 30."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'value_not_in_list',
|
||||
'scheduler',
|
||||
'scheduler',
|
||||
{
|
||||
received_value: 'not-a-scheduler'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value not-a-scheduler for KSampler's scheduler is not available.",
|
||||
toastMessage:
|
||||
"The value not-a-scheduler for KSampler's scheduler is not available."
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves local runtime errors without cloud credit copy', () => {
|
||||
it('falls back to generic copy when structured values cannot be formatted', () => {
|
||||
const circularValue: Record<string, unknown> = {}
|
||||
circularValue.self = circularValue
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
isCloud: false,
|
||||
nodeDisplayName: 'KSampler',
|
||||
error: runtimeError()
|
||||
}).toastMessage
|
||||
).toBe(
|
||||
'This node threw an error during execution. Check its inputs or try a different configuration.'
|
||||
)
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'invalid_input_type',
|
||||
'steps',
|
||||
"steps, [object Object], invalid literal for int() with base 10: 'abc'",
|
||||
{
|
||||
input_config: ['INT', {}],
|
||||
received_value: circularValue
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: "KSampler couldn't convert steps to the expected type.",
|
||||
toastMessage: "KSampler couldn't convert steps to the expected type."
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw details when validation itself fails unexpectedly', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_inner_validation',
|
||||
'images',
|
||||
'list index out of range'
|
||||
),
|
||||
nodeDisplayName: 'Image Scale'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Validation failed',
|
||||
displayMessage: "The workflow couldn't validate a connected node.",
|
||||
displayDetails:
|
||||
"Image Scale couldn't validate images: list index out of range",
|
||||
displayItemLabel: 'Image Scale - images',
|
||||
toastTitle: 'Validation failed',
|
||||
toastMessage:
|
||||
"Image Scale couldn't validate images: list index out of range"
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_validation',
|
||||
undefined,
|
||||
'tuple index out of range'
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Validation failed',
|
||||
displayMessage:
|
||||
'The workflow could not be validated because a node validation check failed unexpectedly.',
|
||||
displayDetails:
|
||||
'Preview Image failed during validation: tuple index out of range',
|
||||
displayItemLabel: 'Preview Image',
|
||||
toastTitle: 'Validation failed',
|
||||
toastMessage:
|
||||
'Preview Image failed during validation: tuple index out of range'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_validation',
|
||||
undefined,
|
||||
''
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: 'Preview Image failed during validation.',
|
||||
toastMessage: 'Preview Image failed during validation.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves custom validation image failures as image-not-loaded copy', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'image',
|
||||
'image - Invalid image file: broken.png'
|
||||
),
|
||||
nodeDisplayName: 'Load Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image.",
|
||||
displayDetails:
|
||||
"The image for Load Image couldn't be loaded. Try adding it again.",
|
||||
displayItemLabel: 'Load Image',
|
||||
toastTitle: "Input image couldn't be loaded"
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'image',
|
||||
"[Errno 21] Is a directory: '/app/comfyui/input'"
|
||||
),
|
||||
nodeDisplayName: 'Load Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image.",
|
||||
displayItemLabel: 'Load Image'
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw details for generic custom validation failures', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'setting',
|
||||
'setting - Unsupported lab value: bad-value'
|
||||
),
|
||||
nodeDisplayName: 'Custom Validation Error'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'custom_validation_failed',
|
||||
displayTitle: 'Invalid input',
|
||||
displayMessage: 'A node rejected one or more input values.',
|
||||
displayDetails:
|
||||
'Custom Validation Error failed custom validation: setting - Unsupported lab value: bad-value',
|
||||
displayItemLabel: 'Custom Validation Error - setting',
|
||||
toastTitle: 'Invalid input',
|
||||
toastMessage: 'Custom Validation Error rejected the value for setting.'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat raw details as the input name when input metadata is missing', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
undefined,
|
||||
'Traceback line 1\nTraceback line 2'
|
||||
),
|
||||
nodeDisplayName: 'Custom Validation Error'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayItemLabel: 'Custom Validation Error - unknown input',
|
||||
toastMessage:
|
||||
'Custom Validation Error rejected the value for unknown input.'
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw cycle paths for dependency cycle details', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'dependency_cycle',
|
||||
undefined,
|
||||
'7 (ImageScale) -> 7 (ImageScale)'
|
||||
),
|
||||
nodeDisplayName: 'Image Scale'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Invalid workflow',
|
||||
displayMessage: 'The workflow has a circular node connection.',
|
||||
displayDetails:
|
||||
'Image Scale is part of a circular connection: 7 (ImageScale) to 7 (ImageScale)',
|
||||
displayItemLabel: 'Image Scale',
|
||||
toastTitle: 'Invalid workflow',
|
||||
toastMessage: 'Image Scale is part of a circular connection.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves known prompt errors with run error rules', () => {
|
||||
@@ -135,6 +453,7 @@ describe('errorMessageResolver', () => {
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Prompt has no outputs',
|
||||
displayMessage:
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
})
|
||||
@@ -182,6 +501,91 @@ describe('errorMessageResolver', () => {
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('resolves newly cataloged prompt-level errors', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'missing_node_type',
|
||||
message:
|
||||
"Node 'ID #4' has no class_type. The workflow may be corrupted or a custom node is missing.",
|
||||
details: "Node ID '#4'"
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Missing node type',
|
||||
displayMessage:
|
||||
'A node type is missing or unavailable. The workflow may be corrupted or require a custom node.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'OOMError',
|
||||
message: 'OOMError: Workflow execution failed',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'out_of_memory',
|
||||
displayTitle: 'Generation failed',
|
||||
displayMessage:
|
||||
'Not enough GPU memory. Try reducing complexity and run again. No credits charged.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: false,
|
||||
error: {
|
||||
type: 'OOMError',
|
||||
message: 'OOMError: Workflow execution failed',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'out_of_memory',
|
||||
displayTitle: 'Generation failed',
|
||||
displayMessage:
|
||||
'Not enough GPU memory. Try reducing complexity and run again.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'ImageDownloadError',
|
||||
message: 'ImageDownloadError: Failed to validate images',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'prompt_outputs_failed_validation',
|
||||
message: 'Prompt outputs failed validation',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Prompt validation failed',
|
||||
displayMessage:
|
||||
'The workflow has invalid node inputs. Fix the highlighted nodes before running it again.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves missing error group display copy', () => {
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
|
||||
@@ -9,22 +9,33 @@ import { st, t, te } from '@/i18n'
|
||||
|
||||
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
|
||||
const REQUIRED_INPUT_MISSING_CATALOG_ID = 'missing_connection'
|
||||
const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
|
||||
const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
|
||||
const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'prompt_no_outputs',
|
||||
'no_prompt',
|
||||
'server_error'
|
||||
'server_error',
|
||||
'missing_node_type',
|
||||
'prompt_outputs_failed_validation'
|
||||
])
|
||||
|
||||
interface ValidationCatalogRule {
|
||||
catalogId: string
|
||||
itemLabel: 'node' | 'nodeInput'
|
||||
copyKeys?: CopyKeys
|
||||
}
|
||||
|
||||
interface ErrorResolveContext {
|
||||
isCloud?: boolean
|
||||
nodeDisplayName?: string
|
||||
}
|
||||
|
||||
type CatalogParams = Record<string, string | number>
|
||||
|
||||
function translateCatalogMessage(
|
||||
key: string,
|
||||
fallback: string,
|
||||
params?: Record<string, string | number>
|
||||
params?: CatalogParams
|
||||
): string {
|
||||
if (te(key)) return params ? t(key, params) : t(key)
|
||||
if (!params) return fallback
|
||||
@@ -34,6 +45,15 @@ function translateCatalogMessage(
|
||||
)
|
||||
}
|
||||
|
||||
function translateOptionalCatalogMessage(
|
||||
key: string,
|
||||
fallback?: string,
|
||||
params?: CatalogParams
|
||||
): string | undefined {
|
||||
if (te(key)) return params ? t(key, params) : t(key)
|
||||
return fallback?.trim() ? fallback : undefined
|
||||
}
|
||||
|
||||
function normalizeNodeName(nodeDisplayName: string | undefined): string {
|
||||
return (
|
||||
nodeDisplayName?.trim() ||
|
||||
@@ -42,99 +62,362 @@ function normalizeNodeName(nodeDisplayName: string | undefined): string {
|
||||
}
|
||||
|
||||
function getInputName(error: NodeValidationError): string {
|
||||
const inputName = error.extra_info?.input_name ?? error.details
|
||||
const inputName = error.extra_info?.input_name
|
||||
return (
|
||||
inputName?.trim() ||
|
||||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
|
||||
)
|
||||
}
|
||||
|
||||
function isRequiredInputMissing(
|
||||
error: NodeValidationError
|
||||
): error is NodeValidationError & { type: typeof REQUIRED_INPUT_MISSING_TYPE } {
|
||||
return error.type === REQUIRED_INPUT_MISSING_TYPE
|
||||
function getErrorText(error: NodeValidationError) {
|
||||
return [
|
||||
'message' in error ? error.message : undefined,
|
||||
'details' in error ? error.details : undefined
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function isImageNotLoadedText(text: string): boolean {
|
||||
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
|
||||
}
|
||||
|
||||
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
|
||||
return (
|
||||
error.type === 'custom_validation_failed' &&
|
||||
isImageNotLoadedText(getErrorText(error))
|
||||
)
|
||||
}
|
||||
|
||||
function nodeInputItemLabel(nodeName: string, inputName: string): string {
|
||||
return `${nodeName} - ${inputName}`
|
||||
}
|
||||
|
||||
function formatDependencyCycleDetails(details: string): string {
|
||||
// Core reports dependency cycle paths as "node -> node"; catalog copy embeds
|
||||
// those paths in prose, where "to" reads more naturally.
|
||||
return details.replace(/\s*->\s*/g, ' to ')
|
||||
}
|
||||
|
||||
function formatCatalogValue(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getInputConfigValue(
|
||||
error: NodeValidationError,
|
||||
key: 'min' | 'max'
|
||||
): string | undefined {
|
||||
const inputConfig = error.extra_info?.input_config
|
||||
if (!Array.isArray(inputConfig)) return undefined
|
||||
|
||||
const config = inputConfig[1]
|
||||
if (!config || typeof config !== 'object') return undefined
|
||||
|
||||
return formatCatalogValue((config as Record<string, unknown>)[key])
|
||||
}
|
||||
|
||||
function getInputConfigType(error: NodeValidationError): string | undefined {
|
||||
const inputConfig = error.extra_info?.input_config
|
||||
if (!Array.isArray(inputConfig)) return undefined
|
||||
|
||||
return formatCatalogValue(inputConfig[0])
|
||||
}
|
||||
|
||||
function getValidationParams(
|
||||
error: NodeValidationError,
|
||||
nodeName: string,
|
||||
inputName: string
|
||||
): CatalogParams {
|
||||
const params: CatalogParams = { nodeName, inputName }
|
||||
const receivedValue = formatCatalogValue(error.extra_info?.received_value)
|
||||
const receivedType = formatCatalogValue(error.extra_info?.received_type)
|
||||
const expectedType = getInputConfigType(error)
|
||||
const minValue = getInputConfigValue(error, 'min')
|
||||
const maxValue = getInputConfigValue(error, 'max')
|
||||
|
||||
if (receivedValue !== undefined) params.receivedValue = receivedValue
|
||||
if (receivedType !== undefined) params.receivedType = receivedType
|
||||
if (expectedType !== undefined) params.expectedType = expectedType
|
||||
if (minValue !== undefined) params.minValue = minValue
|
||||
if (maxValue !== undefined) params.maxValue = maxValue
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
function hasParams(params: CatalogParams, keys: string[]): boolean {
|
||||
return keys.every((key) => params[key] !== undefined)
|
||||
}
|
||||
|
||||
interface CopyKeys {
|
||||
detailsKey: string
|
||||
toastMessageKey: string
|
||||
}
|
||||
|
||||
const DEFAULT_COPY_KEYS: CopyKeys = {
|
||||
detailsKey: 'details',
|
||||
toastMessageKey: 'toastMessage'
|
||||
}
|
||||
|
||||
const VALUE_SPECIFIC_COPY_RULES: Record<
|
||||
string,
|
||||
{
|
||||
requiredParams: string[]
|
||||
suffix: 'WithTypes' | 'WithValue'
|
||||
}
|
||||
> = {
|
||||
return_type_mismatch: {
|
||||
requiredParams: ['expectedType', 'receivedType'],
|
||||
suffix: 'WithTypes'
|
||||
},
|
||||
invalid_input_type: {
|
||||
requiredParams: ['receivedValue', 'expectedType'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_smaller_than_min: {
|
||||
requiredParams: ['receivedValue', 'minValue'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_bigger_than_max: {
|
||||
requiredParams: ['receivedValue', 'maxValue'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_not_in_list: {
|
||||
requiredParams: ['receivedValue'],
|
||||
suffix: 'WithValue'
|
||||
}
|
||||
}
|
||||
|
||||
function getValueSpecificCopyKeys(
|
||||
errorType: string,
|
||||
params: CatalogParams
|
||||
): CopyKeys {
|
||||
const rule = VALUE_SPECIFIC_COPY_RULES[errorType]
|
||||
if (!rule || !hasParams(params, rule.requiredParams)) return DEFAULT_COPY_KEYS
|
||||
|
||||
return {
|
||||
detailsKey: `details${rule.suffix}`,
|
||||
toastMessageKey: `toastMessage${rule.suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
return error.details.trim()
|
||||
? {
|
||||
detailsKey: 'detailsWithRawDetails',
|
||||
toastMessageKey: 'toastMessageWithRawDetails'
|
||||
}
|
||||
: DEFAULT_COPY_KEYS
|
||||
}
|
||||
|
||||
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
if (!error.details.trim()) return DEFAULT_COPY_KEYS
|
||||
|
||||
return {
|
||||
detailsKey: 'detailsWithRawDetails',
|
||||
toastMessageKey: 'toastMessage'
|
||||
}
|
||||
}
|
||||
|
||||
function getValidationCopyKeys(
|
||||
error: NodeValidationError,
|
||||
params: CatalogParams
|
||||
): CopyKeys {
|
||||
if (error.type === 'exception_during_validation') {
|
||||
return getRawDetailsCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'exception_during_inner_validation') {
|
||||
return getRawDetailsCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'custom_validation_failed') {
|
||||
return getRawDetailsOnlyCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'dependency_cycle') {
|
||||
return getRawDetailsOnlyCopyKeys(error)
|
||||
}
|
||||
|
||||
return getValueSpecificCopyKeys(error.type, params)
|
||||
}
|
||||
|
||||
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
|
||||
[REQUIRED_INPUT_MISSING_TYPE]: {
|
||||
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
bad_linked_input: {
|
||||
catalogId: 'bad_linked_input',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
return_type_mismatch: {
|
||||
catalogId: 'return_type_mismatch',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
invalid_input_type: {
|
||||
catalogId: 'invalid_input_type',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_smaller_than_min: {
|
||||
catalogId: 'value_smaller_than_min',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_bigger_than_max: {
|
||||
catalogId: 'value_bigger_than_max',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_not_in_list: {
|
||||
catalogId: 'value_not_in_list',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
custom_validation_failed: {
|
||||
catalogId: 'custom_validation_failed',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_inner_validation: {
|
||||
catalogId: 'exception_during_inner_validation',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_validation: {
|
||||
catalogId: 'exception_during_validation',
|
||||
itemLabel: 'node'
|
||||
},
|
||||
dependency_cycle: {
|
||||
catalogId: 'dependency_cycle',
|
||||
itemLabel: 'node'
|
||||
}
|
||||
}
|
||||
|
||||
// Image-not-loaded shares the custom_validation_failed type, so it needs a
|
||||
// predicate override to use image_not_loaded locale copy and default copy keys.
|
||||
const IMAGE_NOT_LOADED_VALIDATION_RULE = {
|
||||
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
|
||||
itemLabel: 'node',
|
||||
copyKeys: DEFAULT_COPY_KEYS
|
||||
} satisfies ValidationCatalogRule
|
||||
|
||||
function resolveValidationCatalogCopy(
|
||||
error: NodeValidationError,
|
||||
context: ErrorResolveContext,
|
||||
localeKey: string,
|
||||
rule: ValidationCatalogRule
|
||||
): ResolvedErrorMessage {
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const inputName = getInputName(error)
|
||||
const trimmedDetails = error.details.trim()
|
||||
const rawDetails =
|
||||
error.type === 'dependency_cycle'
|
||||
? formatDependencyCycleDetails(trimmedDetails)
|
||||
: trimmedDetails
|
||||
const params = {
|
||||
...getValidationParams(error, nodeName, inputName),
|
||||
rawDetails
|
||||
}
|
||||
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
|
||||
const titleFallback = error.message || error.type
|
||||
const itemLabelFallback =
|
||||
rule.itemLabel === 'node'
|
||||
? nodeName
|
||||
: nodeInputItemLabel(nodeName, inputName)
|
||||
const copyKeys = rule.copyKeys ?? getValidationCopyKeys(error, params)
|
||||
|
||||
return {
|
||||
catalogId: rule.catalogId,
|
||||
displayTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.title`,
|
||||
titleFallback,
|
||||
params
|
||||
),
|
||||
displayMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.message`,
|
||||
error.message,
|
||||
params
|
||||
),
|
||||
displayDetails: translateOptionalCatalogMessage(
|
||||
`${keyPrefix}.${copyKeys.detailsKey}`,
|
||||
error.details,
|
||||
params
|
||||
),
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
itemLabelFallback,
|
||||
params
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
titleFallback,
|
||||
params
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.${copyKeys.toastMessageKey}`,
|
||||
error.message,
|
||||
params
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeValidationErrorMessage(
|
||||
error: NodeValidationError,
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
if (!isRequiredInputMissing(error)) return {}
|
||||
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const inputName = getInputName(error)
|
||||
const keyPrefix = 'errorCatalog.validationErrors.required_input_missing'
|
||||
|
||||
return {
|
||||
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
|
||||
displayTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.title`,
|
||||
'Missing connection'
|
||||
),
|
||||
displayMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.message`,
|
||||
'Required input slots have no connection feeding them.'
|
||||
),
|
||||
displayDetails: translateCatalogMessage(
|
||||
`${keyPrefix}.details`,
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
{ nodeName, inputName }
|
||||
),
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
'{nodeName} - {inputName}',
|
||||
{ nodeName, inputName }
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
'Required input missing'
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.toastMessage`,
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
{ nodeName, inputName }
|
||||
if (isImageNotLoadedValidationError(error)) {
|
||||
return resolveValidationCatalogCopy(
|
||||
error,
|
||||
context,
|
||||
'image_not_loaded',
|
||||
IMAGE_NOT_LOADED_VALIDATION_RULE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutionErrorMessage(
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const keyPrefix = 'errorCatalog.runtimeErrors.execution_failed'
|
||||
const toastMessageKey = context.isCloud
|
||||
? `${keyPrefix}.toastMessageCloud`
|
||||
: `${keyPrefix}.toastMessageLocal`
|
||||
const toastMessageFallback = context.isCloud
|
||||
? 'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
|
||||
: 'This node threw an error during execution. Check its inputs or try a different configuration.'
|
||||
const rule = VALIDATION_ERROR_RULES[error.type]
|
||||
if (!rule) return {}
|
||||
|
||||
return {
|
||||
catalogId: EXECUTION_FAILED_CATALOG_ID,
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
nodeName,
|
||||
{
|
||||
nodeName
|
||||
}
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
'{nodeName} failed',
|
||||
{ nodeName }
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
toastMessageKey,
|
||||
toastMessageFallback,
|
||||
{ nodeName }
|
||||
)
|
||||
}
|
||||
return resolveValidationCatalogCopy(error, context, error.type, rule)
|
||||
}
|
||||
|
||||
function resolvePromptErrorMessage(
|
||||
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error'],
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
if (error.type === 'ImageDownloadError') {
|
||||
return {
|
||||
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
|
||||
displayTitle: st(
|
||||
'errorCatalog.promptErrors.image_not_loaded.title',
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(
|
||||
'errorCatalog.promptErrors.image_not_loaded.desc',
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (error.type === 'OOMError') {
|
||||
const messageKey = context.isCloud
|
||||
? 'errorCatalog.promptErrors.out_of_memory.descCloud'
|
||||
: 'errorCatalog.promptErrors.out_of_memory.descLocal'
|
||||
|
||||
return {
|
||||
catalogId: OUT_OF_MEMORY_CATALOG_ID,
|
||||
displayTitle: st(
|
||||
'errorCatalog.promptErrors.out_of_memory.title',
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(messageKey, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {}
|
||||
|
||||
const errorTypeKey =
|
||||
@@ -145,6 +428,10 @@ function resolvePromptErrorMessage(
|
||||
: error.type
|
||||
|
||||
return {
|
||||
displayTitle: translateCatalogMessage(
|
||||
`errorCatalog.promptErrors.${errorTypeKey}.title`,
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(
|
||||
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
|
||||
error.message
|
||||
@@ -231,11 +518,6 @@ export function resolveRunErrorMessage(
|
||||
return resolveNodeValidationErrorMessage(source.error, {
|
||||
nodeDisplayName: source.nodeDisplayName
|
||||
})
|
||||
case 'execution':
|
||||
return resolveExecutionErrorMessage({
|
||||
isCloud: source.isCloud,
|
||||
nodeDisplayName: source.nodeDisplayName
|
||||
})
|
||||
case 'prompt':
|
||||
return resolvePromptErrorMessage(source.error, {
|
||||
isCloud: source.isCloud
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { NodeError, PromptError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
@@ -39,12 +35,6 @@ export type RunErrorMessageSource =
|
||||
error: NodeValidationError
|
||||
nodeDisplayName: string
|
||||
}
|
||||
| {
|
||||
kind: 'execution'
|
||||
error: ExecutionErrorWsMessage
|
||||
nodeDisplayName?: string
|
||||
isCloud: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'prompt'
|
||||
error: PromptError
|
||||
|
||||
@@ -54,15 +54,30 @@ describe('getWidgetIdentity', () => {
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey for widgets without stable identity', () => {
|
||||
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey when no nodeId is available at all', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(
|
||||
widget,
|
||||
undefined,
|
||||
3
|
||||
)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
|
||||
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
@@ -360,6 +375,46 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses duplicate normal widgets on the same node to one render', () => {
|
||||
const colorA = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const colorB = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'ColorToRGBInt',
|
||||
widgets: [colorA, colorB],
|
||||
title: 'Color to RGB Int',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('color')
|
||||
expect(result[0].renderKey).toBe('node:1:color:color:color')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
|
||||
@@ -129,11 +129,15 @@ export function getWidgetIdentity(
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const hostNodeIdRoot =
|
||||
nodeId !== undefined && nodeId !== ''
|
||||
? `node:${String(stripGraphPrefix(nodeId))}`
|
||||
: undefined
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: undefined
|
||||
: hostNodeIdRoot
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
||||
|
||||
@@ -4,6 +4,7 @@ export const WidgetInputBaseClass = cn([
|
||||
// Background
|
||||
'not-disabled:bg-component-node-widget-background',
|
||||
'not-disabled:text-component-node-foreground',
|
||||
'[[readonly]]:bg-component-node-widget-background-disabled',
|
||||
// Outline
|
||||
'border-none',
|
||||
// Rounded
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IColorWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
const widgets: IColorWidget[] = []
|
||||
const addWidget = vi.fn(
|
||||
(
|
||||
type: string,
|
||||
name: string,
|
||||
value: string,
|
||||
_callback: () => void,
|
||||
options: IWidgetOptions
|
||||
) => {
|
||||
const widget = {
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
options,
|
||||
callback: _callback
|
||||
} as unknown as IColorWidget
|
||||
widgets.push(widget)
|
||||
return widget
|
||||
}
|
||||
)
|
||||
|
||||
return { widgets, addWidget } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
const colorSpec: InputSpec = {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
default: '#ffffff',
|
||||
socketless: true
|
||||
}
|
||||
|
||||
describe('useColorWidget', () => {
|
||||
it('reads the top-level default from the V2 spec', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, colorSpec)
|
||||
expect(widget.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('falls back to nested options.default when top-level default is absent', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
options: { default: '#abcdef' }
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#abcdef')
|
||||
})
|
||||
|
||||
it('falls back to #000000 when no default is declared', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color'
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#000000')
|
||||
})
|
||||
|
||||
it('returns the existing widget instead of creating a duplicate', () => {
|
||||
const node = createMockNode()
|
||||
const first = useColorWidget()(node, colorSpec)
|
||||
const second = useColorWidget()(node, colorSpec)
|
||||
expect(second).toBe(first)
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,14 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
|
||||
const { name, options } = inputSpec as ColorInputSpec
|
||||
const defaultValue = options?.default || '#000000'
|
||||
const colorSpec = inputSpec as ColorInputSpec
|
||||
const { name, options } = colorSpec
|
||||
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
|
||||
|
||||
const existing = node.widgets?.find(
|
||||
(w): w is IColorWidget => w.name === name && w.type === 'color'
|
||||
)
|
||||
if (existing) return existing
|
||||
|
||||
const widget = node.addWidget('color', name, defaultValue, () => {}, {
|
||||
serialize: true
|
||||
|
||||
Reference in New Issue
Block a user