Compare commits

...

10 Commits

Author SHA1 Message Date
pythongosssss
aefcc22767 Merge branch 'main' into pysssss/fix-disabled-widget-visibility 2026-05-25 15:33:08 +01:00
pythongosssss
cddb393d95 fix read-only tw selector matching divs 2026-05-25 06:59:29 -07:00
pythongosssss
4e79625244 revert 2026-05-25 06:12:53 -07:00
jaeone94
c0ef283a05 General execution error messaging (#12448)
## Summary

This PR builds on the error catalog display resolver foundation from
#12402 and adds the first broader catalog pass for general
execution-related messaging. The goal is to keep the raw API contract
intact (`message` / `details`) while adding resolved display fields that
the UI can prefer when catalog copy exists.

The main functional sample in this PR is validation error messaging. It
expands the resolver beyond `required_input_missing` so common node
validation failures can show friendlier titles, grouped messages, detail
copy, item labels, and toast copy without overwriting the original
backend payload.

## What changed

- Added catalog copy for known node validation errors:
  - `required_input_missing` as `missing_connection`
  - `bad_linked_input`
  - `return_type_mismatch`
  - `invalid_input_type`
  - `value_smaller_than_min`
  - `value_bigger_than_max`
  - `value_not_in_list`
  - `custom_validation_failed`
  - `exception_during_inner_validation`
  - `exception_during_validation`
  - `dependency_cycle`
- Added an `image_not_loaded` validation override for
`custom_validation_failed` messages that indicate invalid image files or
directory paths.
- Added value-aware validation details when Core provides structured
`extra_info`, including received values, expected/received types, and
min/max bounds.
- Added prompt-level catalog handling for known prompt errors that
already have stable types/copy, including missing node type, prompt
output validation, image download, and OOM prompt errors.
- Preserved runtime execution errors as raw API copy for now, so
service-level or actionable runtime failures are not hidden behind
generic catalog text before targeted runtime handling lands.
- Added/updated English `errorCatalog` i18n keys for the new validation
and prompt catalog copy.
- Added resolver and grouping tests for the new catalog paths, raw
fallback behavior, runtime raw preservation, prompt copy, and
image-not-loaded detection.

## Screenshots (diff)
### Before  
<img width="371" height="346" alt="Old_1"
src="https://github.com/user-attachments/assets/bd474869-7428-4f68-a067-bb412aa95d3b"
/>
<img width="373" height="296" alt="Old_2"
src="https://github.com/user-attachments/assets/fc393792-dc6d-46fb-b7df-20290b35e30e"
/>
<img width="370" height="292" alt="Old_3"
src="https://github.com/user-attachments/assets/bcb867ea-12ba-49b7-887a-ce06afa60475"
/>
<img width="370" height="269" alt="Old_4"
src="https://github.com/user-attachments/assets/05caeff8-2597-4c95-97cf-2736825b85f3"
/>
<img width="371" height="292" alt="Old_5"
src="https://github.com/user-attachments/assets/dd58113e-5953-4701-b597-d59cb6e124e9"
/>
<img width="373" height="282" alt="Old_6"
src="https://github.com/user-attachments/assets/60fb02c0-4ed6-4734-926c-f8a20f0aeb1c"
/>
<img width="371" height="279" alt="Old_7"
src="https://github.com/user-attachments/assets/a3453b5c-c779-4f43-af27-97cc9a083480"
/>
<img width="370" height="292" alt="Old_8"
src="https://github.com/user-attachments/assets/59d08636-c1b3-4cde-a340-befb48726ee8"
/>
<img width="371" height="276" alt="Old_9"
src="https://github.com/user-attachments/assets/7a94465b-ed5c-4ad9-a40a-cfe3c08d3dc7"
/>
<img width="368" height="279" alt="Old_10"
src="https://github.com/user-attachments/assets/3f791ff3-e3e3-4cb7-aab1-640ec1cee751"
/>
<img width="370" height="276" alt="Old_11"
src="https://github.com/user-attachments/assets/9c0f28c2-4f60-4f38-b3c4-5560609e329e"
/>
<img width="370" height="279" alt="Old_12"
src="https://github.com/user-attachments/assets/4b61545e-db7e-4512-b300-e883ab37f347"
/>

### After
<img width="426" height="301" alt="New_1"
src="https://github.com/user-attachments/assets/9874c036-2b3d-4b7c-ac3d-cb9c396c597f"
/>
<img width="421" height="301" alt="New_2"
src="https://github.com/user-attachments/assets/38cd0f35-53a4-490a-b47f-da21eaa44fc8"
/>
<img width="418" height="347" alt="New_3"
src="https://github.com/user-attachments/assets/db5ab3cc-f246-407d-b80b-9ad92c95c7ad"
/>
<img width="425" height="327" alt="New_4"
src="https://github.com/user-attachments/assets/4333c2b8-3077-4122-9719-21d56a7b2230"
/>
<img width="424" height="325" alt="New_5"
src="https://github.com/user-attachments/assets/6616d61f-fa90-4d2f-b8fd-50ac5a3f32cb"
/>
<img width="423" height="326" alt="New_6"
src="https://github.com/user-attachments/assets/02a4f97a-708e-4c00-b061-d8e4dcaacd8f"
/>
<img width="424" height="323" alt="New_7"
src="https://github.com/user-attachments/assets/9d1e96c9-69de-4e26-a152-1a101675c5eb"
/>
<img width="425" height="327" alt="New_8"
src="https://github.com/user-attachments/assets/ffa66faf-1a33-43a3-b604-25352195f28c"
/>
<img width="425" height="323" alt="New_9"
src="https://github.com/user-attachments/assets/f7eb5f0c-4d0c-4f1b-aa3d-30358fbc9943"
/>
<img width="423" height="328" alt="New_10"
src="https://github.com/user-attachments/assets/72665c97-ec61-4e5a-b702-379baf919822"
/>
<img width="423" height="351" alt="New_11"
src="https://github.com/user-attachments/assets/c5376f02-7a62-42e6-9cda-e50ab6d41b04"
/>
<img width="425" height="326" alt="New_12"
src="https://github.com/user-attachments/assets/413df105-dc7e-4289-90b0-30ecaa417c84"
/>


## Intentional boundaries


This PR does not add targeted runtime/cloud-specific message matching
yet. Runtime execution errors still use the original exception message
and traceback in the error panel. This is intentional because
cloud/service runtime errors can include actionable strings such as
auth, payment, rate limit, timeout, moderation, or infrastructure
failures, and collapsing those too early would make the UX worse.

This PR also does not change the overlay or right-side panel design. It
only prepares and fills resolved display fields so the next stacked PRs
can consume them with much less plumbing.

## Follow-up PR plan

- Add targeted runtime/cloud-specific messaging for high-volume errors
such as credits, timeouts, disallowed content, rate limit,
sign-in/payment requirements, and server crash style failures.
- Revisit runtime execution grouping once runtime catalog IDs are
explicit enough to group by message category rather than node class or
raw exception text.
- Update the error overlay to use single-error toast title/message
fields and multi-error aggregate copy.
- Update the right-side error panel design, including item labels such
as `Node name - input/widget name`.
- Consider splitting `errorMessageResolver.ts` by error family
(`validation`, `prompt`, `runtime`, `cloud-specific`) before adding more
runtime-specific rules.

## Validation

- `pnpm exec vitest run
src/platform/errorCatalog/errorMessageResolver.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm typecheck`
- Commit hooks ran staged formatting, lint fixes, and `pnpm typecheck`.
- Push hook ran `knip --cache`; it completed with an existing tag-hint
warning for `src/scripts/metadata/flac.ts`.
2026-05-25 12:54:47 +00:00
pythongosssss
4fcbee9a8a fix: improve disabled vue node widget contrast 2026-05-25 05:00:20 -07:00
Dante
d405002127 fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12447)
## Summary

Fix the duplicate \`<WidgetColorPicker>\` rendering on the \`Color to
RGB Int\` node (and any other COLOR-using V3 node that the runtime
double-registers a widget for).
<img width="480" alt="after-fix-dedupe-proof"
src="https://github.com/user-attachments/assets/5c801806-ed5d-493f-92b6-e0b99dd8e408"
/>

## Changes

- **What**:
- \`useProcessedWidgets.getWidgetIdentity\`: fall back to the host
\`nodeId\` parameter for the dedupe identity root when neither
\`storeNodeId/widget.nodeId\` nor \`sourceExecutionId\` is set. Normal
root-graph widgets now dedupe identically to promoted/execution-scoped
widgets, so any duplicate same-name+same-type widget collapses to one
render. \`sourceExecutionId\` precedence is preserved.
- \`useColorWidget\`: read top-level \`default\` from the V2 spec (falls
back to nested \`options.default\` for hand-authored V2 specs), and
short-circuit if a same-name color widget already exists on
\`node.widgets\` so a second \`addWidget('color', …)\` call from
upstream hooks (or a \`configure\` round-trip) no longer duplicates the
row.
- **Tests**:
- New \`useColorWidget.test.ts\` covers top-level default,
nested-options fallback, no-default fallback, and the idempotency guard.
- \`useProcessedWidgets.test.ts\` gets a regression case for two
identical color widgets on the same node collapsing to one render, plus
an updated \`getWidgetIdentity\` case for the host-nodeId fallback.

## Review Focus

- \`getWidgetIdentity\` precedence change. The fallback only fires when
none of \`storeNodeId\`, \`widget.nodeId\`, or \`sourceExecutionId\` are
present, so promoted/exec-scoped widgets (incl. the \"unresolved
same-name promoted entries distinct by source execution identity\"
\`NodeWidgets\` test) are unaffected.
- \`useColorWidget\` idempotency guard is defensive — the root cause of
the second \`addWidget\` call (cloud-only hook or persisted
\`info.widgets\` configure round-trip) is not in this diff; that's
tracked separately.

Fixes
[FE-842](https://linear.app/comfyorg/issue/FE-842/color-to-rgb-int-node-shows-duplicate-color-widgets)
2026-05-25 11:33:58 +00:00
pythongosssss
abd233d10d feat: default search to essentials when graph is empty (#12377)
## Summary

Currently, when opening node search on an empty graph, the default view
shows "Most Relevant" nodes, which includes nodes like CLIP and VAE. For
users building from scratch, these nodes are not necessarily the most
helpful starting point.

## Changes

- **What**: 
- Update default mode to Essentials when graph is empty

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12377-feat-default-search-to-essentials-when-graph-is-empty-3666d73d3650816d9d5ae3ed602a30ec)
by [Unito](https://www.unito.io)
2026-05-25 09:35:52 +00:00
Comfy Org PR Bot
e1049a99a3 1.46.1 (#12445)
Patch version increment to 1.46.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-25 01:33:11 +00:00
Jukka Seppänen
3da6e1766e feat: optional retain camera view on Load3D model reload (#12440)
When comparing outputs from 3D generations, it's very hard to see small
differences since the camera always resets. This adds an option to lock
the camera, so only the model refreshes.

## Summary

Adds an opt-in per-node toggle that preserves the current camera view
(position, target, zoom, camera type) across model loads in Load3D /
Load3DAnimation nodes, instead of resetting to default framing.

## Changes

- **What**: New `retainViewOnReload?: boolean` field on `CameraConfig`,
a `Load3d.setRetainViewOnReload()` setter wired through the existing
`useLoad3d` camera-config watcher, capture/restore logic in
`Load3d._loadModelInternal`, and a lock-icon toggle button in
`CameraControls.vue` below the FOV slider. Preference persists via the
existing `node.properties['Camera Config']` mechanism.

## Review Focus

- **First-load semantics**: retain only kicks in once a model has
successfully loaded at least once (`hasLoadedModel` flag), so the
default `setupForModel` framing wins on a fresh node. `clearModel()`
resets the flag so the next load also reframes.
- **Restore order vs. `SceneModelManager.setupModel`**: the scene model
manager unconditionally calls `setupForModel` during a load, which
clobbers the camera. The restore in `_loadModelInternal` runs *after*
the load completes, on top of that framing.
- **Camera-type mismatch**: if the saved state's `cameraType` differs
from the currently active camera, `toggleCamera()` runs before
`setCameraState()` so the perspective/orthographic camera being restored
is actually the active one. Covered by a dedicated test.
- **Scope**: only wired through `useLoad3d` (LiteGraph node controls).
The full-page viewer (`useLoad3dViewer` / `ViewerCameraControls`) is
deliberately not extended — the modal is mostly a one-shot
view-and-close flow, so retain there would add surface area for an
uncommon use case.
- **Failed loads**: `hasLoadedModel` only flips inside `if
(modelManager.currentModel)`, so a load that produces no model leaves
the flag where it was. Captured camera state is still applied on top,
which effectively no-ops since nothing reset it.


## Video


https://github.com/user-attachments/assets/880d6ad1-28a9-4413-83a3-8323d05d904a
2026-05-23 08:47:30 -04:00
Comfy Org PR Bot
52830a9e73 1.46.0 (#12439)
Minor version increment to 1.46.0

**Base branch:** `main`

---------

Co-authored-by: dante01yoon <6510430+dante01yoon@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-23 18:16:38 +09:00
39 changed files with 1974 additions and 339 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
retainViewOnReload?: boolean
}
export interface LightConfig {

View File

@@ -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": "إعادة تعيين جميع المعلمات",

View File

@@ -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."
}
}
},

View File

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

View File

@@ -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": "بازنشانی همه پارامترها",

View File

@@ -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 sagir dune erreur système inattendue."
},
"prompt_no_outputs": {
"desc": "Le workflow ne contient aucun nœud de sortie (par exemple, Enregistrer limage, Prévisualiser limage) 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 lexécution. Vérifiez ses entrées ou essayez une autre configuration. Aucun crédit na été déduit.",
"toastMessageLocal": "Ce nœud a généré une erreur lors de lexé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",

View File

@@ -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": "すべてのパラメータをリセット",

View File

@@ -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": "모든 매개변수 재설정",

View File

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

View File

@@ -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": "Сбросить все параметры",

View File

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

View File

@@ -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": "重設所有參數",

View File

@@ -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": "重置所有参数",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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