Compare commits

..

13 Commits

Author SHA1 Message Date
jaeone94
2b4cd9a152 Merge branch 'main' into jaeone/fe-816-special-runtime-error-messaging 2026-05-28 17:06:59 +09:00
jaeone94
c3dc7f45d4 Fix load3d unused exported camera types (#12505)
## Summary

This PR fixes a `knip` failure introduced by exported Load3D camera
helper interfaces that are only used inside
`src/extensions/core/load3d/interfaces.ts`.

## Problem

`knip --cache` reports these exported types as unused exports:

- `CameraQuaternion`
- `CameraRotation`
- `CameraFrustum`

They are implementation details for the local `CameraState` interface
and are not imported outside the module.

## Fix

Remove `export` from those three interfaces and keep them as file-local
types. This keeps the `CameraState` shape unchanged while avoiding an
unnecessary public type surface.

## Validation

Ran successfully:

- `pnpm exec oxfmt --write src/extensions/core/load3d/interfaces.ts`
- `pnpm typecheck`
- `pnpm knip --cache`

Notes:

- `pnpm knip --cache` still reports the existing tag hints for `flac.ts`
and `apps/website/src/utils/video.ts`, but no longer reports the Load3D
unused exported types.
2026-05-28 17:05:39 +09:00
jaeone94
e123f4f36c Tidy runtime resolver review follow-ups 2026-05-28 16:20:58 +09:00
jaeone94
7450ed6823 Remove credit note from runtime error copy 2026-05-28 16:14:43 +09:00
jaeone94
56bd47892b feat: add special runtime error messaging 2026-05-28 16:14:43 +09:00
Terry Jia
c2ef961834 feat: output model_info from Load3D node (#12494)
Expose per-object gizmo transforms (uuid, name, type, position,
rotation, quaternion, scale, up, visible, matrix) as a new `model_info`
output on the Load3D node.

`GizmoManager.getModelInfo()` reads the live target object and the
Load3D widget `serializeValue` writes it into the node payload. The
payload is a list to support multiple objects later; the viewer
currently renders a single main object, so it emits a one-element list.

Requires backend Comfy-Org/ComfyUI#14144 (adds the `LOAD3D_MODEL_INFO`
type and the output socket).
2026-05-27 23:54:46 -04:00
Terry Jia
78c16368d7 feat: expose camera intrinsics in Load3D camera_info (#12492)
## Summary
Add quaternion, rotation, fov, aspect, near, far and orthographic
frustum bounds (left/right/top/bottom) to the camera state captured by
CameraManager.getCameraState(), so the Load3D camera_info output carries
enough information for backend nodes to fully reconstruct the camera.

https://github.com/Comfy-Org/ComfyUI/pull/14143

Quaternion and Euler rotation are serialized as plain objects to avoid
THREE.js private underscore-prefixed fields leaking into the payload.

<img width="1807" height="1333" alt="image"
src="https://github.com/user-attachments/assets/8c0a6ab9-be5f-40d1-8015-7bd0d6c731a5"
/>
2026-05-27 23:09:46 -04:00
Christian Byrne
8206022982 fix(subgraph): validate URL hash and redirect to root when subgraph missing (#12169)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fix FE-559: browser forward/back to a deleted subgraph used to leave the
canvas on stale state (and sometimes triggered unrelated tab navigation)
because the subgraph id in the URL hash was looked up with no validation
or fallback.

## Changes

- **What**:
- Added `src/schemas/subgraphIdSchema.ts` — `zSubgraphId =
z.string().uuid()` + `isValidSubgraphId(value)` type guard, matching how
subgraph ids are persisted in `workflowSchema.ts` and generated by
`createUuidv4()`.
- `subgraphNavigationStore.navigateToHash()` now (a) validates the hash
with `isValidSubgraphId` before any lookup, (b) redirects to the root
graph (`router.replace('#' + root.id)` + `canvas.setGraph(root)`) when
the locator is malformed, missing from `root.subgraphs`, or still
unresolved after a workflow-load attempt.
- Replaced the `console.error('subgraph poofed after load?')` dead-end
with the same redirect helper.
- Re-ordered the "already on this graph" short-circuit so a stale canvas
reference to a now-deleted subgraph doesn't suppress the redirect.

## Review Focus

- TDD: 6 new tests in `subgraphNavigationStore.navigateToHash.test.ts`
cover valid navigation, deleted-subgraph hash, malformed (non-UUID)
hash, no-op when target equals current, empty-hash root case, and
stale-canvas recovery. 15 new tests in `subgraphIdSchema.test.ts` lock
down the validator.
- `redirectToRoot()` toggles `blockHashUpdate` while calling
`router.replace`, so the new redirect doesn't re-trigger `updateHash()`
and clobber the canvas state.
- Generalized validation: the new schema lives in `src/schemas/` and can
be reused anywhere a subgraph id crosses an untrusted boundary (URL,
IPC, etc.).

## Manual Verification

Ran ComfyUI backend (`--cpu --port 8188`) + frontend dev server, then
drove Playwright through three scenarios:

| Input hash | Result | Console |
|---|---|---|
| `#11111111-2222-4333-8444-555555555555` (UUID-shaped, non-existent) |
URL replaced with `#<root-id>` | `[subgraphNavigation] subgraph not
found: 11111111-…; redirecting to root graph` |
| `#not-a-valid-uuid` (malformed) | URL replaced with `#<root-id>` |
`[subgraphNavigation] invalid subgraph id in hash: not-a-valid-uuid;
redirecting to root graph` |
| `#aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee` (UUID-shaped, non-existent) |
URL replaced with `#<root-id>` | (same redirect message) |

Screenshot below shows the redirected viewport.

Fixes FE-559

## Screenshots

![ComfyUI canvas after a hash referencing a deleted subgraph was
rewritten to the root graph
hash](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/fe7f8846b3efdc95461cd63995dd10808073dd86c561eff9d8816742eb892687/pr-images/1778562546959-43f5ead4-3e13-45de-a0ac-988c3424368b.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12169-fix-subgraph-validate-URL-hash-and-redirect-to-root-when-subgraph-missing-35e6d73d3650819f840af1475b9f44d4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-28 00:34:42 +00:00
Dante
5f2b2f2e87 fix: show cloud models in IC-LoRA Loader Model Only node (FE-838) (#12488)
## Summary

### before
<img width="1107" height="958" alt="before-buggy"
src="https://github.com/user-attachments/assets/1fcbd909-e008-4bd3-967f-87cdabb2baf6"
/>

### after
<img width="1107" height="958" alt="after-fixed"
src="https://github.com/user-attachments/assets/0d3c6f3f-36d6-4556-bd29-b3826ae20216"
/>


The **IC-LoRA Loader Model Only** node (`LTXICLoRALoaderModelOnly`, from
ComfyUI-LTXVideo) didn't show cloud models from `supported_models.json`,
while the native **Load LoRA** node did.

## Changes

- **What**: Add `['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']` to
`MODEL_NODE_MAPPINGS`. Whether a combo widget swaps to the cloud asset
browser is gated by `assetService.shouldUseAssetBrowser` →
`isAssetBrowserEligible`, which only returns true for node types
registered in `MODEL_NODE_MAPPINGS` (via `modelToNodeStore`). The custom
IC-LoRA loader was absent from that list, so its `lora_name` widget fell
back to the plain combo that lists only filesystem models — never the
cloud-injected ones.
- **Breaking**: none

## Review Focus

Root cause verified live on `cloud.comfy.org` (asset API enabled, custom
node installed) via CDP:
- `LoraLoaderModelOnly` (native) → registry `lora_name`, eligible `true`
→ cloud models shown
- `LTXICLoRALoaderModelOnly` (bug) → not in registry, eligible `false` →
cloud models missing
- After registering the mapping live → eligible `true`, category `loras`
→ cloud models shown

Same class of bug as FE-492 (custom loaders missing from the mapping);
long-term, auto-detecting model-folder-backed combos would remove the
need to register each custom loader by hand.

Fixes FE-838

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| [`test:`
64d099f6c](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26508637513)
| 🔴 Red (failure) | Proves the test catches the bug |
| [`fix:`
6b91a570d](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26509067631)
| 🟢 Green (success) | Proves the fix resolves it |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit regression in `src/stores/modelToNodeStore.test.ts`
- [ ] E2E not applicable (custom node + cloud asset API not available in
CI)
2026-05-27 22:51:26 +00:00
Dante
a931acadd3 feat(dialog): migrate Settings dialog to Reka-UI (Phase 3) (#12182)
## Summary

Phase 3 of the dialog migration. Closes the parity gaps in the Reka
renderer (maximize affordance, headless layout mode, overlay-class
plumbing), then flips `useSettingsDialog` onto the Reka path. Public API
of `useDialogService` / `dialogStore` is unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-575](https://linear.app/comfyorg/issue/FE-575/phase-3-migrate-settings-dialog-workspace-non-workspace-designer)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged), #12109
(Phase 2, **stacked PR base**)

> **Stacked on Phase 2**: this PR targets
`jaewon/dialog-reka-migration-phase-2`. Rebase onto `main` after #12109
lands.

## Changes

### Reka primitives — parity gaps closed

| File | Change |
| --- | --- |
| `src/components/ui/dialog/dialog.variants.ts` | New `maximized`
variant. `false` keeps the centered/sized layout; `true` switches to
`inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none` for
full-screen mode |
| `src/components/ui/dialog/DialogContent.vue` | Accepts `maximized`
prop, forwards to variants |
| `src/components/ui/dialog/DialogMaximize.vue` **(new)** | Icon-only
button toggling `lucide--maximize-2` / `lucide--minimize-2`; emits
`toggle`; uses `g.maximizeDialog` / `g.restoreDialog` i18n |
| `src/stores/dialogStore.ts` | Adds `overlayClass?:
HTMLAttributes['class']` to `CustomDialogComponentProps` (Reka-only;
PrimeVue path uses `pt.mask`) |
| `src/components/dialog/GlobalDialog.vue` | (a) Forwards `overlayClass`
to `DialogOverlay`; (b) passes `:maximized` to `DialogContent`; (c)
renders `DialogMaximize` in the header when `maximizable`, wired to a
local `toggleMaximize`; (d) when `headless: true`, skips the inner
`flex-1 overflow-auto px-4 py-2` wrapper so layout dialogs control their
own chrome |

### Settings flip

| File | Change |
| --- | --- |
| `src/platform/settings/composables/useSettingsDialog.ts` | Adds
`dialogComponentProps: { renderer: 'reka', size: 'full', contentClass:
'\<...\>', overlayClass }`. `contentClass` is `w-[90vw] max-w-[960px]
sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden` —
matches the previous `BaseModalLayout size="sm"` (960px × 80vh).
`overlayClass: 'p-8'` only when `isCloud && teamWorkspacesEnabled`
(preserves the workspace breathing-room contract) |
| `src/components/dialog/GlobalDialog.vue` | Drops the now-dead
`getDialogPt` workspace special case and the orphan
`.settings-dialog-workspace` CSS. Removes unused imports (`merge`,
`computed`, `useFeatureFlags`, `isCloud`, `DialogPassThroughOptions`) |

### Tests

- `src/platform/settings/composables/useSettingsDialog.test.ts`
**(new)** — 5 tests: renderer flip + sizing, workspace `overlayClass`
toggle, panel forwarding, `showAbout()`

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — 0 errors (3 pre-existing warnings unrelated to this
PR)
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent areas):
  - `useSettingsDialog.test.ts` — 5/5
  - `dialogService.renderer.test.ts` — 5/5
  - `GlobalDialog.test.ts` — 9/9
  - All `src/components/dialog/` — 73/73
  - All `src/platform/settings/` — 75/75
  - `CustomizationDialog.test.ts` — 4/4
- [ ] CI Playwright matrix
- [ ] Manual verification on a backend

## Screenshots

End-to-end verification of the Reka flip on a local dev server:

| | |
| --- | --- |
| Settings dialog rendered via Reka (non-modal, focus stays in dialog
body) |
![Settings](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/settings-dialog-reka.png)
|
| Keybinding panel inside the Reka Settings dialog |
![Keybinding](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/keybinding-panel.png)
|
| Nested PrimeVue **Modify keybinding** dialog stacked on top —
`document.activeElement` is the `<input autofocus>`, proving the
focus-trap fix | ![Modify
keybinding](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/nested-modify-keybinding.png)
|


## Public API impact

None. `useSettingsDialog().show()` keeps the same signature. Reka
primitives gain optional `maximized` prop and `overlayClass` field —
additive, non-breaking.

## Out of scope (later phases)

- Manager dialog — Phase 4 (FE-576) — will consume the new `maximizable`
affordance
- `ConfirmDialog` callers — Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue` —
Phase 6 (FE-578)

## Review focus

1. **Sizing strategy** — `contentClass` overrides Reka's default content
sizing (matching the existing `BaseModalLayout size="sm"` of 960 ×
80vh). Worth a designer pass per FE-575's acceptance criteria.
2. **`overlayClass: 'p-8'` workspace mode** — Reka's `DialogContent` is
positioned with viewport coordinates, so overlay padding does not
constrain it the way the old PrimeVue `mask.p-8` did. Cosmetic gutter
only. If designer flags missing breathing room, follow-up by shrinking
`contentClass` in workspace mode.
3. **`headless: true` semantics for Reka** — now skips the inner padding
wrapper. Existing migrated dialogs (Phases 1–2) all set a header, so no
visible impact. The Reka-headless path is new with this PR.
4. **Maximize wiring** — `toggleMaximize` mutates
`item.dialogComponentProps.maximized` directly (Pinia deep-reactive
proxy). The store's `onMaximize` / `onUnmaximize` callbacks are still
wired for the PrimeVue path; not double-fired.

## Test plan

- [x] Unit: 102/102 across touched + adjacent areas
- [ ] CI: full Vitest + Playwright matrix
- [ ] Manual on a backend:
- Open Settings via gear icon / keyboard shortcut → renders through
Reka, search works, panel navigation works, ESC closes
- Open Settings → trigger a reset confirmation (stacked confirm) →
confirm renders above Settings, ESC closes only the confirm
- Cloud workspace mode: Settings opens with workspace panel;
`overlayClass` applied
- Cloud non-workspace mode: Settings opens without workspace panel; no
`overlayClass`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12182-feat-dialog-migrate-Settings-dialog-to-Reka-UI-Phase-3-35e6d73d36508144bb4af88f83c5ab20)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-27 22:08:30 +00:00
Luke Mino-Altherr
db6b7a315c chore: remediate 51 Dependabot vulnerabilities (#12345)
## Summary

Remediate 51 of 63 open Dependabot security alerts by bumping direct
dependencies, bumping parent dependencies, and adding targeted pnpm
overrides for transitive dependencies.

## Changes

- **What**: Two batches of dependency security fixes
- **Batch 1**: Bump catalog minimums for axios, dompurify, happy-dom,
vite, uuid. Fix axios header type narrowing in api.ts.
- **Batch 2**: Bump parent deps (@iconify/tailwind4, vue, knip) to pull
fixed transitive deps. Add tilde-pinned pnpm overrides for protobufjs,
flatted, defu where no parent fix is available. Unexport 6 unused types
flagged by knip upgrade.
- **Dependencies**: vue 3.5.13->3.5.34 required two type fixes
(LazyImage ClassValue, dialogStore deep instantiation)

## Review Focus

- pnpm overrides in package.json: protobufjs ~7.6.0, flatted ~3.4.2,
defu ~6.1.7
- Vue 3.5.34 type narrowing fixes in LazyImage.vue and dialogStore.ts

## Remaining (12 alerts, separate PRs)

- minimatch (4H) - 4 major version lines, needs per-consumer analysis
- picomatch (2M) - two major version lines
- brace-expansion (2M) - multiple major version lines
- astro (2: 1L+1M) - major version bump 5->6
- postcss 8.5.8 (1M) - dev-only, from @vue/compiler-sfc@3.5.28 via
storybook/devtools
- yaml 1.10.2 (1M) - from cosmiconfig->nx, no upstream fix in yaml v1
- lodash/lodash-es (4: 2H+2M) - dev-only, upstream still uses 4.17.x
- @babel/plugin-transform-modules-systemjs (1H) - dev-only via nx
- fast-uri (2H) - dev-only via ajv->nx/stylelint

Fixes #FE-762

---------

Co-authored-by: Austin Mroz <austin@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-27 14:07:34 -07:00
AustinMroz
b89940134f Better preview grid tiling (#12463)
The previous image preview tiling code was less than ideal. It had fixed
breakpoints based on the number of images. Outputs with many images
would become comically long.

This PR instead tiles images to fill the available space.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/e793ce65-8efc-44ca-b049-98f066a65b7d"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/ca891ce2-335f-42ce-aeec-a99579f669c8"
/>|
2026-05-27 20:26:44 +00:00
Christian Byrne
7ac1cbbd53 test: add E2E coverage for NE, SW, NW corner node resizing (#11408)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Adds parameterized Playwright E2E tests covering all non-SE resize
corners (NE, SW, NW), closing the coverage gap in the `useNodeResize.ts`
switch statement
- Adds `resizeFromCorner()` and `getResizeHandle()` to `VueNodeFixture`
for reuse across tests
- Test cases are derived from the production `RESIZE_HANDLES` config so
they stay in sync with the actual handle definitions

## Test Groups (8 new tests)

| Group | Tests | Coverage |
|-------|-------|----------|
| Corner resize directions | NE, SW, NW — size increases and correct
edges shift | Lines 110-124, 184 |
| Opposite edge anchoring | NE, SW, NW — opposite corner stays fixed |
Position compensation end-to-end |
| Minimum size enforcement | SW width clamp (≥ MIN_NODE_WIDTH), NE
height clamp | Lines 162-176 |

## Design Decisions

**Locator-based handle discovery**: `resizeFromCorner()` finds handles
via `getByRole('button', { name: ariaLabel })` instead of coordinate
offsets. The resize handles have `opacity-0 pointer-events-auto`,
meaning they're always interactive even when visually transparent —
Playwright considers elements with `opacity: 0` as visible (it only
gates on `visibility: hidden` / `display: none` / zero-size bounding
box). If this approach turns out to be flaky in CI due to handle
discoverability, we can fall back to coordinate-based targeting
(computing offsets from the node's bounding box corners), which is what
the original SE-corner test uses.

**Parameterization from production config**: Tests import
`RESIZE_HANDLES` from `resizeHandleConfig.ts` and derive test case data
(drag direction, which axes move) from the corner name. An upfront guard
throws if any expected corner is missing from the config, preventing
silent coverage loss.

**Aria-label coupling**: `RESIZE_HANDLE_LABELS` in `VueNodeFixture`
hardcodes the English aria-label strings. This is intentional — tests
run in English locale, and aria-labels are the accessibility interface
contract. If a more stable hook is needed (e.g., `data-testid` per
handle), that can be added to `LGraphNode.vue` in a follow-up.

**Frame settlement**: `resizeFromCorner()` calls `nextFrame()` after the
mouse-up to ensure layout settles before assertions run, per
`FLAKE_PREVENTION_RULES.md`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11408-test-add-E2E-coverage-for-NE-SW-NW-corner-node-resizing-3476d73d3650818d8a5ce5d6d535b38c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-27 17:26:59 +00:00
87 changed files with 4673 additions and 2027 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
export type NavDropdownItem = {
type NavDropdownItem = {
label: string
href: string
badge?: string

View File

@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
interface DroppedRole {
title: string
reason: string
}

View File

@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedNode {
interface DroppedNode {
name: string
reason: string
}

View File

@@ -66,34 +66,6 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.drop(options)
}
async middleDrag(
from: Position,
to: Position,
options: Omit<DragOptions, 'button'> = {}
) {
await this.dragAndDrop(from, to, { ...options, button: 'middle' })
}
async middleDragFromCenter(
locator: Locator,
delta: { x: number; y: number },
options: Omit<DragOptions, 'button'> = {}
) {
await locator.waitFor({ state: 'visible' })
const box = await locator.boundingBox()
if (!box) throw new Error('middleDragFromCenter: bounding box not found')
const start = {
x: box.x + box.width / 2,
y: box.y + box.height / 2
}
await this.middleDrag(
start,
{ x: start.x + delta.x, y: start.y + delta.y },
options
)
}
/** @see {@link Mouse.move} */
async move(to: Position, options = ComfyMouse.defaultOptions) {
await this.mouse.move(to.x, to.y, options)

View File

@@ -128,7 +128,8 @@ export const TestIds = {
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot'
slotConnectionDot: 'slot-connection-dot',
imageGrid: 'image-grid'
},
selectionToolbox: {
root: 'selection-toolbox',

View File

@@ -1,8 +1,15 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
interface BoxOrigin {
readonly x: number
readonly y: number
}
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
@@ -15,7 +22,9 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly imageGrid: Locator
public readonly content: Locator
public readonly resize: { bottomRight: Locator }
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -28,7 +37,10 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
this.content = locator.locator('.lg-node-content')
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
this.resize = { bottomRight }
}
async getTitle(): Promise<string> {
@@ -77,4 +89,100 @@ export class VueNodeFixture {
: slotLocators.filter({ has: nameOrLocator })
return filteredLocator.getByTestId('slot-dot').locator('..')
}
/**
* Click the node header to select it, then return its bounding box.
* Throws if the node is not laid out because geometry-sensitive tests
* cannot proceed without coordinates.
*/
async selectAndGetBox(): Promise<{
x: number
y: number
width: number
height: number
}> {
await this.header.click()
const box = await this.boundingBox()
if (!box) {
throw new Error('Node bounding box not found after select')
}
return box
}
/**
* Assert this node's top-left origin stays within `precision` decimal
* places of `expected`. Wraps the polled bounding-box pattern that drift
* tests repeat for both axes.
*/
async expectAnchoredAt(
expected: BoxOrigin,
{ precision = 1 }: { precision?: number } = {}
): Promise<void> {
await expect.poll(this.pollLeftEdge).toBeCloseTo(expected.x, precision)
await expect.poll(this.pollTopEdge).toBeCloseTo(expected.y, precision)
}
/** Poll the node's left/x edge for use with `expect.poll`. */
pollLeftEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.x ?? null
/** Poll the node's top/y edge for use with `expect.poll`. */
pollTopEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.y ?? null
/** Poll the node's right edge (x + width) for use with `expect.poll`. */
pollRightEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.x + b.width : null
}
/** Poll the node's bottom edge (y + height) for use with `expect.poll`. */
pollBottomEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.y + b.height : null
}
/** Poll the node's width for use with `expect.poll`. */
pollWidth = async (): Promise<number | null> =>
(await this.boundingBox())?.width ?? null
/** Poll the node's height for use with `expect.poll`. */
pollHeight = async (): Promise<number | null> =>
(await this.boundingBox())?.height ?? null
/** Locator for the resize handle at the given corner, scoped to this node. */
getResizeHandle(corner: CompassCorners): Locator {
return this.root.locator(`[data-corner="${corner}"]`)
}
/**
* Drag the resize handle at `corner` by (deltaX, deltaY) viewport pixels.
* Uses `hover()` to land the pointer on the handle with Playwright's
* actionability checks before starting the mouse sequence, which protects
* against occluding overlays and subpixel hit-test misses.
*/
async resizeFromCorner(
corner: CompassCorners,
deltaX: number,
deltaY: number
): Promise<void> {
const handle = this.getResizeHandle(corner)
await handle.hover()
const box = await handle.boundingBox()
if (!box) {
throw new Error(
`Resize handle for corner "${corner}" has no bounding box`
)
}
const page = this.locator.page()
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX + deltaX, startY + deltaY, {
steps: 5
})
await page.mouse.up()
}
}

View File

@@ -76,34 +76,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await maskEditor.drawStrokeAndExpectPixels(dialog)
})
test(
'Middle-click drag should pan the mask editor canvas',
{ tag: ['@canvas'] },
async ({ comfyPage, comfyMouse, maskEditor }) => {
const dialog = await maskEditor.openDialog()
const pointerZone = dialog.getByTestId('pointer-zone')
const getCanvasPosition = () =>
comfyPage.page.evaluate(() => {
const container = document.querySelector('#maskEditorCanvasContainer')
if (!(container instanceof HTMLElement)) return null
return {
left: container.style.left,
top: container.style.top
}
})
const canvasPositionBefore = await getCanvasPosition()
await comfyMouse.middleDragFromCenter(
pointerZone,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect.poll(getCanvasPosition).not.toEqual(canvasPositionBefore)
}
)
test('undo reverts a brush stroke', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,87 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
async function waitForRootCanvasReady(page: Page) {
await expect
.poll(async () => {
const state = await page.evaluate(() => ({
rootId: window.app?.rootGraph?.id ?? '',
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
}))
return state.rootId !== '' && state.canvasGraphId === state.rootId
})
.toBe(true)
}
async function expectCanvasOnRootGraph(page: Page) {
await expect
.poll(async () =>
page.evaluate(() => ({
rootId: window.app!.rootGraph.id,
canvasGraphId: window.app!.canvas.graph?.id,
hash: window.location.hash
}))
)
.toEqual({
rootId: expect.any(String),
canvasGraphId: expect.stringMatching(/.+/),
hash: expect.stringMatching(/^#.+/)
})
const state = await page.evaluate(() => ({
rootId: window.app!.rootGraph.id,
canvasGraphId: window.app!.canvas.graph?.id,
hash: window.location.hash
}))
expect(state.canvasGraphId).toBe(state.rootId)
expect(state.hash).toBe(`#${state.rootId}`)
}
test.describe(
'Subgraph hash validation (FE-559)',
{ tag: ['@subgraph'] },
() => {
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
comfyPage
}) => {
await waitForRootCanvasReady(comfyPage.page)
const rootId = await comfyPage.page.evaluate(
() => window.app!.rootGraph.id
)
const phantomId = '11111111-1111-4111-8111-111111111111'
expect(phantomId).not.toBe(rootId)
await comfyPage.page.evaluate((hash) => {
window.location.hash = hash
}, `#${phantomId}`)
await expect
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
timeout: 5000
})
.toBe(`#${rootId}`)
await expectCanvasOnRootGraph(comfyPage.page)
})
test('redirects URL and canvas to root when hash is malformed', async ({
comfyPage
}) => {
await waitForRootCanvasReady(comfyPage.page)
const rootId = await comfyPage.page.evaluate(
() => window.app!.rootGraph.id
)
await comfyPage.page.evaluate(() => {
window.location.hash = '#not-a-valid-uuid'
})
await expect
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
timeout: 5000
})
.toBe(`#${rootId}`)
await expectCanvasOnRootGraph(comfyPage.page)
})
}
)

View File

@@ -4,29 +4,6 @@ import {
} from '@e2e/fixtures/ComfyPage'
test.describe('Vue Nodes Canvas Pan', { tag: '@vue-nodes' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test(
'Middle-click drag on a Vue node pans canvas',
{ tag: ['@canvas'] },
async ({ comfyPage, comfyMouse }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const offsetBefore = await comfyPage.canvasOps.getOffset()
await comfyMouse.middleDragFromCenter(
node,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBefore)
}
)
test(
'@mobile Can pan with touch',
{ tag: '@screenshot' },

View File

@@ -1,11 +1,15 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
@@ -136,3 +140,44 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
}
)
})
async function countColumns(locator: Locator) {
return await locator.locator('img').evaluateAll((images) => {
const yOffsets = images.map((image) => image.getBoundingClientRect().y)
return yOffsets.filter((yOffset) => yOffset === yOffsets[0]).length
})
}
test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
wstest(
'Image previews tile to fit node',
async ({ comfyMouse, comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Inject multiple previews', async () => {
const file = { filename: 'example.png', type: 'input' }
const images = new Array(100).fill(file)
execution.executed('', '1', { images })
await expect(node.imageGrid.locator('img')).toHaveCount(100)
})
const { bottomRight } = node.resize
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
})

View File

@@ -1,56 +1,165 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
import {
RESIZE_HANDLES,
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
test.describe('Vue Node Resizing', { tag: '@vue-nodes' }, () => {
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
async function setupResizableNode(comfyPage: ComfyPage, title: string) {
await expect(comfyPage.vueNodes.getNodeByTitle(title)).toHaveCount(1)
const node = await comfyPage.vueNodes.getFixtureByTitle(title)
const box = await node.selectAndGetBox()
return { node, box }
}
// Select the node first (this was causing the bug)
await node.header.click()
test.describe(
'Vue Node Resizing',
{ tag: ['@vue-nodes', '@canvas', '@node'] },
() => {
let originalMinimapVisible: boolean | undefined
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
// Minimap overlays the canvas and intercepts pointer events that land in
// its hit area during resize drags, so disable it for this suite. Capture
// and restore the prior value to avoid leaking the override to other specs
// that run on the same user-data-dir.
test.beforeEach(async ({ comfyPage }) => {
originalMinimapVisible = await comfyPage.settings.getSetting<boolean>(
'Comfy.Minimap.Visible'
)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.canvasOps.resetView()
})
// Verify position unchanged after selection
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
test.afterEach(async ({ comfyPage }) => {
if (originalMinimapVisible !== undefined) {
await comfyPage.settings.setSetting(
'Comfy.Minimap.Visible',
originalMinimapVisible
)
}
})
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
const { node, box: initialBox } = await setupResizableNode(
comfyPage,
'Load Checkpoint'
)
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
await node.expectAnchoredAt(initialBox)
// Position should NOT have changed (the bug was position drift)
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
await node.resizeFromCorner('SE', 50, 30)
// Size should have increased
await expect
.poll(async () => (await node.boundingBox())?.width)
.toBeGreaterThan(initialBox.width)
await expect
.poll(async () => (await node.boundingBox())?.height)
.toBeGreaterThan(initialBox.height)
})
})
await node.expectAnchoredAt(initialBox)
await expect.poll(node.pollWidth).toBeGreaterThan(initialBox.width)
await expect.poll(node.pollHeight).toBeGreaterThan(initialBox.height)
})
const cornerCases = RESIZE_HANDLES.map((h) => ({
corner: h.corner,
dragX: hasWestEdge(h.corner) ? -50 : 50,
dragY: hasNorthEdge(h.corner) ? -40 : 40
}))
test.describe('corner resize directions', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner}: size increases and correct edges shift`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(node.pollWidth).toBeGreaterThan(box.width)
await expect.poll(node.pollHeight).toBeGreaterThan(box.height)
if (hasWestEdge(corner)) {
await expect.poll(node.pollLeftEdge).toBeLessThan(box.x)
} else {
await expect.poll(node.pollLeftEdge).toBeCloseTo(box.x, 0)
}
if (hasNorthEdge(corner)) {
await expect.poll(node.pollTopEdge).toBeLessThan(box.y)
} else {
await expect.poll(node.pollTopEdge).toBeCloseTo(box.y, 0)
}
})
})
})
test.describe('opposite edge anchoring', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner} resize keeps opposite corner fixed`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const pollAnchorX = hasWestEdge(corner)
? node.pollRightEdge
: node.pollLeftEdge
const pollAnchorY = hasNorthEdge(corner)
? node.pollBottomEdge
: node.pollTopEdge
const anchorX = hasWestEdge(corner) ? box.x + box.width : box.x
const anchorY = hasNorthEdge(corner) ? box.y + box.height : box.y
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(pollAnchorX).toBeCloseTo(anchorX, 0)
await expect.poll(pollAnchorY).toBeCloseTo(anchorY, 0)
})
})
})
test.describe('minimum size enforcement', () => {
test('SW resize clamps width, keeping right edge fixed', async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const rightEdge = box.x + box.width
await node.resizeFromCorner('SW', box.width + 100, 0)
await expect.poll(node.pollRightEdge).toBeCloseTo(rightEdge, 0)
await expect.poll(node.pollWidth).toBeGreaterThanOrEqual(MIN_NODE_WIDTH)
})
test('NE resize clamps height at its lower bound', async ({
comfyPage
}) => {
const { node } = await setupResizableNode(comfyPage, 'KSampler')
// Default nodes render at content-minimum height; grow from SE so NE
// has room to shrink back down to the clamp.
await node.resizeFromCorner('SE', 0, 200)
const expandedBox = await node.boundingBox()
if (!expandedBox)
throw new Error('Node bounding box not found after SE grow')
const bottomEdge = expandedBox.y + expandedBox.height
// Overdrag once to hit the clamp, then again to prove further dragging
// does not shrink past the minimum (idempotent clamp).
await node.resizeFromCorner('NE', 0, expandedBox.height + 100)
const clampedHeight = (await node.boundingBox())?.height
if (clampedHeight === undefined)
throw new Error('Node bounding box not found after NE clamp')
expect(clampedHeight).toBeLessThan(expandedBox.height)
await node.resizeFromCorner('NE', 0, 200)
await expect.poll(node.pollHeight).toBeCloseTo(clampedHeight, 0)
await expect.poll(node.pollBottomEdge).toBeCloseTo(bottomEdge, 0)
})
})
}
)

View File

@@ -5,10 +5,6 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const getFirstClipNode = (comfyPage: ComfyPage) =>
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
@@ -58,23 +54,4 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
test(
'Middle-click drag on textarea should pan canvas',
{ tag: ['@canvas', '@widget'] },
async ({ comfyPage, comfyMouse }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const offsetBefore = await comfyPage.canvasOps.getOffset()
await comfyMouse.middleDragFromCenter(
textarea,
{ x: 140, y: 90 },
{ steps: 10 }
)
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBefore)
}
)
})

View File

@@ -43,7 +43,6 @@ const config: KnipConfig = {
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
],
ignore: [

View File

@@ -97,7 +97,7 @@
"axios": "catalog:",
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "^3.2.5",
"dompurify": "catalog:",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
@@ -193,7 +193,7 @@
"unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:",
"uuid": "^11.1.0",
"uuid": "catalog:",
"vite": "catalog:",
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",

View File

@@ -1892,3 +1892,17 @@ audio.comfy-audio.empty-audio-widget {
300% 14px;
background-attachment: local, local, scroll, scroll;
}
/*
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
body { pointer-events: none } via DismissableLayer, which propagates to the
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
overlays do not, so they need to opt in here.
*/
.p-select-overlay,
.p-colorpicker-panel,
.p-popover,
.p-autocomplete-overlay {
pointer-events: auto;
}

1685
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ catalog:
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.0
'@iconify/tailwind4': ^1.2.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
@@ -66,10 +66,10 @@ catalog:
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
axios: ^1.15.2
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.3.1
dompurify: ^3.4.5
dotenv: ^16.4.5
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
@@ -87,12 +87,12 @@ catalog:
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.0.11
happy-dom: ^20.8.9
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.3.1
knip: ^6.14.1
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
@@ -108,13 +108,13 @@ catalog:
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
reka-ui: ^2.5.0
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
three: ^0.184.0
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
@@ -123,13 +123,14 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.0
vite: ^8.0.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.0.16
vue: ^3.5.13
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
@@ -160,3 +161,13 @@ overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'
protobufjs: ~7.6.0
flatted: ~3.4.2
defu: ~6.1.7
# Security overrides (see pnpm.overrides in package.json for the actual pins):
# protobufjs ~7.6.0 — CVE-2026-41242 (CVSS 9.8): arbitrary code execution.
# Transitive via firebase, posthog-js. Remove after firebase upgrades protobufjs.
# flatted ~3.4.2 — GHSA-x7hr-w5r2-h6qg: prototype pollution.
# Transitive via eslint flat-cache@4.0.1. Dev-only. Remove after eslint upgrades flat-cache.
# defu ~6.1.7 — GHSA-47f6-5gq3-vx9c: prototype pollution.
# Transitive via reka-ui, c12, unplugin-typegpu. Remove after reka-ui upgrades defu.

View File

@@ -1,91 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
isMiddleButtonEvent,
isMiddleButtonHeld,
isMiddleForPointerEvent,
isMiddlePointerInput
} from '@/base/pointerUtils'
describe('pointerUtils', () => {
describe('isMiddlePointerInput', () => {
it('accepts middle-button pointerdown and strict middle-only buttons', () => {
expect(
isMiddlePointerInput(
new PointerEvent('pointerdown', { button: 1, buttons: 4 })
)
).toBe(true)
expect(
isMiddlePointerInput(new PointerEvent('pointermove', { buttons: 4 }))
).toBe(true)
})
it('rejects chorded pointerdown when middle is only incidentally held', () => {
expect(
isMiddlePointerInput(
new PointerEvent('pointerdown', { button: 0, buttons: 5 })
)
).toBe(false)
})
})
describe('isMiddleButtonHeld', () => {
it('uses the middle-button bit so chorded moves stay active', () => {
expect(
isMiddleButtonHeld(new PointerEvent('pointermove', { buttons: 4 }))
).toBe(true)
expect(
isMiddleButtonHeld(new PointerEvent('pointermove', { buttons: 5 }))
).toBe(true)
expect(
isMiddleButtonHeld(new PointerEvent('pointermove', { buttons: 1 }))
).toBe(false)
})
})
describe('isMiddleButtonEvent', () => {
it('uses the changed button instead of the held-button bitmask', () => {
expect(
isMiddleButtonEvent(new PointerEvent('pointerup', { button: 1 }))
).toBe(true)
expect(
isMiddleButtonEvent(
new MouseEvent('auxclick', { button: 2, buttons: 4 })
)
).toBe(false)
})
})
describe('isMiddleForPointerEvent', () => {
it('dispatches by pointer event type', () => {
expect(
isMiddleForPointerEvent(
new PointerEvent('pointerdown', { button: 0, buttons: 5 })
)
).toBe(false)
expect(
isMiddleForPointerEvent(
new PointerEvent('pointermove', { button: 0, buttons: 5 })
)
).toBe(true)
expect(
isMiddleForPointerEvent(
new PointerEvent('pointerup', { button: 1, buttons: 0 })
)
).toBe(true)
})
it('treats pointercancel like a held-button event', () => {
expect(
isMiddleForPointerEvent(
new PointerEvent('pointercancel', { buttons: 5 })
)
).toBe(true)
expect(
isMiddleForPointerEvent(
new PointerEvent('pointercancel', { buttons: 1 })
)
).toBe(false)
})
})
})

View File

@@ -2,6 +2,11 @@
* Utilities for pointer event handling
*/
/**
* Checks if a pointer or mouse event is a middle button input
* @param event - The pointer or mouse event to check
* @returns true if the event is from the middle button/wheel
*/
export function isMiddlePointerInput(
event: PointerEvent | MouseEvent
): boolean {
@@ -15,25 +20,3 @@ export function isMiddlePointerInput(
return false
}
export function isMiddleButtonHeld(event: PointerEvent | MouseEvent): boolean {
if ('buttons' in event && typeof event.buttons === 'number') {
return (event.buttons & 4) === 4
}
return false
}
export function isMiddleButtonEvent(event: PointerEvent | MouseEvent): boolean {
return 'button' in event && event.button === 1
}
export function isMiddleForPointerEvent(
event: PointerEvent | MouseEvent
): boolean {
if (event.type === 'pointerdown') return isMiddlePointerInput(event)
if (event.type === 'pointermove' || event.type === 'pointercancel') {
return isMiddleButtonHeld(event)
}
return isMiddleButtonEvent(event)
}

View File

@@ -42,7 +42,8 @@ import type { StyleValue } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
import type { ClassValue } from '@comfyorg/tailwind-utils'
type ClassValue = string | Record<string, boolean> | ClassValue[]
const {
src,

View File

@@ -8,6 +8,10 @@ import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
@@ -190,3 +194,88 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false
return {
detail: { originalEvent: { target } },
preventDefault: () => {
prevented = true
},
get defaultPrevented() {
return prevented
}
} as unknown as CustomEvent<{ originalEvent: PointerEvent }> & {
defaultPrevented: boolean
}
}
it.for([
'p-select-overlay',
'p-colorpicker-panel',
'p-popover',
'p-autocomplete-overlay',
'p-overlay-mask',
'p-dialog'
])('prevents dismiss when target is inside %s', (className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
})
it('allows dismiss when target is outside any PrimeVue overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(false)
})
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: false }, event)
expect(event.defaultPrevented).toBe(true)
})
it.for(['p-dialog', 'p-select-overlay'])(
'focus-outside on a sibling %s portal does not dismiss the parent',
(className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
}
)
it('focus-outside still dismisses when focus moves to a non-portal element', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')
document.body.appendChild(portal)
const event = makeEvent(portal)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
portal.remove()
})
})

View File

@@ -8,9 +8,14 @@
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogOverlay
v-reka-z-index
:class="item.dialogComponentProps.overlayClass"
/>
<DialogContent
v-reka-z-index
:size="item.dialogComponentProps.size ?? 'md'"
:maximized="!!item.dialogComponentProps.maximized"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
@@ -19,34 +24,51 @@
e.preventDefault()
"
@pointer-down-outside="
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
"
@focus-outside="onRekaFocusOutside"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<template v-if="item.dialogComponentProps.headless">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
<template v-else>
<DialogHeader>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<div class="flex items-center gap-1">
<DialogMaximize
v-if="item.dialogComponentProps.maximizable"
:maximized="!!item.dialogComponentProps.maximized"
@toggle="toggleMaximize(item)"
/>
<DialogClose
v-if="item.dialogComponentProps.closable !== false"
/>
</div>
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
</DialogContent>
</DialogPortal>
</Dialog>
@@ -55,7 +77,6 @@
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
@@ -86,29 +107,25 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
@@ -119,20 +136,8 @@ function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
function toggleMaximize(item: DialogInstance) {
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
}
</script>
@@ -163,19 +168,6 @@ function getDialogPt(item: {
}
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;

View File

@@ -244,7 +244,7 @@
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
class="z-1800 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"

View File

@@ -0,0 +1,49 @@
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
// elements as outside its dialog and would auto-dismiss on the first
// interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
// Reka portals its own dialogs / popovers / menus into the body too. When a
// nested Reka layer opens on top of a non-modal parent, the parent's
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
// dismiss itself. These selectors cover the portaled roots so we can treat
// interactions on them as inside.
const REKA_PORTAL_SELECTORS =
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
type OutsideEvent = CustomEvent<{ originalEvent: Event }>
function isInsideOverlay(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest(OUTSIDE_LAYER_SELECTORS) !== null
)
}
export function onRekaPointerDownOutside(
options: { dismissableMask?: boolean },
event: OutsideEvent
) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
return
}
if (options.dismissableMask === false) {
event.preventDefault()
}
}
// Focus / interact-outside fires when focus moves to a sibling portal (a
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}
}

View File

@@ -0,0 +1,17 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
// can lose to an already-open PrimeVue dialog. Registering Reka's content
// element with the same ZIndex counter (key 'modal', base 1700) makes both
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set('modal', el, 1700)
},
beforeUnmount(el) {
ZIndex.clear(el)
}
}

View File

@@ -120,7 +120,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -594,7 +594,7 @@ onUnmounted(() => {
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {
if (!isMiddleForPointerEvent(e)) return
if (!isMiddlePointerInput(e)) return
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
return

View File

@@ -167,7 +167,10 @@ describe('TabErrors.vue', () => {
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(
screen.getByText('Node threw an error during execution.')
).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
})
@@ -246,9 +249,9 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
})
it('shows missing model Refresh in the section header when no model is downloadable', async () => {

View File

@@ -46,7 +46,22 @@ vi.mock('@/i18n', () => {
'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.'
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
'errorCatalog.runtimeErrors.execution_failed.message':
'Node threw an error during execution.',
'errorCatalog.runtimeErrors.execution_failed.itemLabel': '{nodeName}',
'errorCatalog.runtimeErrors.execution_failed.toastTitle':
'{nodeName} failed',
'errorCatalog.runtimeErrors.execution_failed.toastMessage':
'This node threw an error during execution. Check its inputs or try a different configuration.',
'errorCatalog.runtimeErrors.out_of_memory.title': 'Generation failed',
'errorCatalog.runtimeErrors.out_of_memory.message':
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
'errorCatalog.runtimeErrors.out_of_memory.itemLabel': '{nodeName}',
'errorCatalog.runtimeErrors.out_of_memory.toastTitle': 'Generation failed',
'errorCatalog.runtimeErrors.out_of_memory.toastMessage':
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
}
const interpolate = (
@@ -158,6 +173,7 @@ function createErrorGroups() {
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockIsCloud.value = false
})
describe('missingPackGroups', () => {
@@ -421,7 +437,8 @@ describe('useErrorGroups', () => {
)
})
it('includes execution error from runtime errors', async () => {
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
@@ -430,7 +447,7 @@ describe('useErrorGroups', () => {
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
exception_message: 'mat1 and mat2 shapes cannot be multiplied',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
@@ -443,15 +460,52 @@ describe('useErrorGroups', () => {
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
message: 'RuntimeError: CUDA out of memory',
message: 'RuntimeError: mat1 and mat2 shapes cannot be multiplied',
details: 'line 1\nline 2',
isRuntimeError: true,
exceptionType: 'RuntimeError'
exceptionType: 'RuntimeError',
catalogId: 'execution_failed',
displayTitle: 'Execution failed',
displayMessage: 'Node threw an error during execution.',
displayItemLabel: 'KSampler',
toastTitle: 'KSampler failed',
toastMessage:
'This node threw an error during execution. Check its inputs or try a different configuration.'
})
// 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('adds display fields for targeted runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'torch.OutOfMemoryError',
exception_message:
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const error = execGroup.cards[0].errors[0]
expect(error.message).toContain('torch.OutOfMemoryError:')
expect(error.catalogId).toBe('out_of_memory')
expect(error.displayMessage).toBe(
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
)
expect(error.displayItemLabel).toBe('KSampler')
expect(error.toastTitle).toBe('Generation failed')
})
it('includes prompt error when present', async () => {

View File

@@ -427,7 +427,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
}
],
filterBySelection

View File

@@ -10,11 +10,13 @@ import { dialogContentVariants } from './dialog.variants'
const {
size,
maximized = false,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
maximized?: boolean
class?: HTMLAttributes['class']
}
>()
@@ -26,7 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size }), customClass)"
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
>
<slot />
</DialogContent>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { maximized = false } = defineProps<{ maximized?: boolean }>()
const emit = defineEmits<{ toggle: [] }>()
const { t } = useI18n()
</script>
<template>
<Button
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
size="icon"
variant="muted-textonly"
@click="emit('toggle')"
>
<i
:class="
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
"
/>
</Button>
</template>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const dialogContentVariants = cva({
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
variants: {
size: {
sm: 'sm:max-w-sm',
@@ -10,14 +10,19 @@ export const dialogContentVariants = cva({
lg: 'sm:max-w-3xl',
xl: 'sm:max-w-5xl',
full: 'sm:max-w-[calc(100vw-1rem)]'
},
maximized: {
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
}
},
defaultVariants: {
size: 'md'
size: 'md',
maximized: false
}
})
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
type DialogContentVariants = VariantProps<typeof dialogContentVariants>
export type DialogContentSize = NonNullable<DialogContentVariants['size']>

View File

@@ -16,7 +16,7 @@ type MockStore = {
isPanning: boolean
}
const mockStore = reactive<MockStore>({
const mockStore: MockStore = reactive({
currentTool: Tools.MaskPen,
activeLayer: 'mask',
pointerZone: null,
@@ -24,7 +24,7 @@ const mockStore = reactive<MockStore>({
brushPreviewGradientVisible: false,
isAdjustingBrush: false,
isPanning: false
})
}) as MockStore
const mockBrushDrawing = {
startDrawing: vi.fn().mockResolvedValue(undefined),
@@ -82,7 +82,7 @@ const mockKeyboard = {
isKeyDown: vi.fn().mockReturnValue(false),
addListeners: vi.fn(),
removeListeners: vi.fn()
} satisfies Parameters<typeof useToolManager>[0]
}
const mockPanZoom = {
initializeCanvasPanZoom: vi.fn(),
@@ -96,43 +96,36 @@ const mockPanZoom = {
invalidatePanZoom: vi.fn(),
addPenPointerId: vi.fn(),
removePenPointerId: vi.fn()
} satisfies Parameters<typeof useToolManager>[1]
type TestPointerEventInit = PointerEventInit & {
offsetX?: number
offsetY?: number
type?: string
}
const pointerEvent = ({
offsetX = 0,
offsetY = 0,
type = 'pointerdown',
...init
}: TestPointerEventInit = {}): PointerEvent => {
const event = new PointerEvent(type, {
const pointerEvent = (
init: Partial<PointerEvent> & { pointerType?: string }
): PointerEvent => {
return {
preventDefault: vi.fn(),
pointerId: 1,
pointerType: 'mouse',
button: 0,
buttons: 0,
clientX: 0,
clientY: 0,
offsetX: 0,
offsetY: 0,
altKey: false,
...init
})
vi.spyOn(event, 'preventDefault')
Object.defineProperties(event, {
offsetX: { value: offsetX },
offsetY: { value: offsetY }
})
return event
} as unknown as PointerEvent
}
let scope: EffectScope | null = null
const setup = (): ReturnType<typeof useToolManager> => {
scope = effectScope()
return scope.run(() => useToolManager(mockKeyboard, mockPanZoom))!
return scope.run(() =>
useToolManager(
mockKeyboard as unknown as Parameters<typeof useToolManager>[0],
mockPanZoom as unknown as Parameters<typeof useToolManager>[1]
)
)!
}
describe('useToolManager', () => {
@@ -314,9 +307,7 @@ describe('useToolManager', () => {
it('should start panning on middle mouse button (buttons===4)', async () => {
const tm = setup()
await tm.handlePointerDown(
pointerEvent({ type: 'pointerdown', buttons: 4 })
)
await tm.handlePointerDown(pointerEvent({ buttons: 4 }))
expect(mockPanZoom.handlePanStart).toHaveBeenCalled()
expect(mockStore.brushVisible).toBe(false)
@@ -443,19 +434,7 @@ describe('useToolManager', () => {
it('should pan on middle button drag', async () => {
const tm = setup()
await tm.handlePointerMove(
pointerEvent({ type: 'pointermove', buttons: 4 })
)
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()
})
it('should keep panning when middle button is held with another button', async () => {
const tm = setup()
await tm.handlePointerMove(
pointerEvent({ type: 'pointermove', buttons: 5 })
)
await tm.handlePointerMove(pointerEvent({ buttons: 4 }))
expect(mockPanZoom.handlePanMove).toHaveBeenCalled()
expect(mockBrushDrawing.handleDrawing).not.toHaveBeenCalled()

View File

@@ -11,7 +11,6 @@ import { useCanvasTools } from './useCanvasTools'
import { useCoordinateTransform } from './useCoordinateTransform'
import type { useKeyboard } from './useKeyboard'
import type { usePanAndZoom } from './usePanAndZoom'
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
import { app } from '@/scripts/app'
export function useToolManager(
@@ -119,10 +118,9 @@ export function useToolManager(
panZoom.addPenPointerId(event.pointerId)
}
if (
isMiddleForPointerEvent(event) ||
(event.buttons === 1 && keyboard.isKeyDown(' '))
) {
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
panZoom.handlePanStart(event)
store.brushVisible = false
@@ -179,10 +177,9 @@ export function useToolManager(
const newCursorPoint = { x: event.clientX, y: event.clientY }
panZoom.updateCursorPosition(newCursorPoint)
if (
isMiddleForPointerEvent(event) ||
(event.buttons === 1 && keyboard.isKeyDown(' '))
) {
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
await panZoom.handlePanMove(event)
return
}

View File

@@ -192,6 +192,7 @@ describe('useLoad3d', () => {
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}),
getModelInfo: vi.fn().mockReturnValue(null),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
renderer: {
@@ -1354,6 +1355,46 @@ describe('useLoad3d', () => {
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('gizmoTransformChange mirrors the live scene into Scene Config models', async () => {
const modelTransform = {
uuid: 'abc',
name: 'mesh',
type: 'Mesh',
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7, order: 'XYZ' },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 3, y: 3, z: 3 },
up: { x: 0, y: 1, z: 0 },
visible: true,
matrix: new Array(16).fill(0)
}
vi.mocked(mockLoad3d.getModelInfo!).mockReturnValue(modelTransform)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const handler = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)![1] as (data: unknown) => void
handler({
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7 },
scale: { x: 3, y: 3, z: 3 },
enabled: true,
mode: 'rotate'
})
await nextTick()
expect(composable.sceneConfig.value.models).toEqual([modelTransform])
const savedScene = mockNode.properties['Scene Config'] as {
models: unknown[]
}
expect(savedScene.models).toEqual([modelTransform])
})
it('should reset gizmo config on model switch (not first load)', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')

View File

@@ -789,6 +789,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const syncSceneModels = () => {
const modelInfo = load3d?.getModelInfo()
sceneConfig.value.models = modelInfo ? [modelInfo] : []
}
const eventConfig = {
materialModeChange: (value: string) => {
modelConfig.value.materialMode = value as MaterialMode
@@ -860,6 +865,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
]
hasSkeleton.value = load3d?.hasSkeleton() ?? false
applyGizmoConfigToLoad3d()
syncSceneModels()
isFirstModelLoad = false
},
modelReady: () => {
@@ -936,6 +942,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelConfig.value.gizmo.enabled = data.enabled
modelConfig.value.gizmo.mode = data.mode
}
syncSceneModels()
}
} as const
@@ -961,6 +968,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const transform = load3d.getGizmoTransform()
modelConfig.value.gizmo.position = transform.position
modelConfig.value.gizmo.scale = transform.scale
syncSceneModels()
}
const handleResetGizmoTransform = () => {

View File

@@ -1,7 +1,7 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
export interface ResolvedPreviewChainStep {
interface ResolvedPreviewChainStep {
rootGraphId: UUID
hostNodeLocator: string
exposure: PreviewExposure

View File

@@ -4,7 +4,7 @@ import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { parseNodePropertyArray } from './parseNodePropertyArray'
export const previewExposureSchema = z.object({
const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourcePreviewName: z.string()

View File

@@ -6,7 +6,7 @@ import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { parseNodePropertyArray } from './parseNodePropertyArray'
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
export const proxyWidgetQuarantineReasonSchema = z.enum([
const proxyWidgetQuarantineReasonSchema = z.enum([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
@@ -18,7 +18,7 @@ export type ProxyWidgetQuarantineReason = z.infer<
typeof proxyWidgetQuarantineReasonSchema
>
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
const proxyWidgetErrorQuarantineEntrySchema = z.object({
originalEntry: serializedProxyWidgetTupleSchema,
reason: proxyWidgetQuarantineReasonSchema,
hostValue: z.unknown().optional(),

View File

@@ -6,7 +6,8 @@ import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
CameraState
CameraState,
ModelInfo
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
@@ -402,6 +403,9 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_info: ModelInfo = modelInfo ? [modelInfo] : []
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
@@ -409,7 +413,8 @@ useExtensionService().registerExtension({
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
recording: ''
recording: '',
model_info
}
const recordingData = currentLoad3d.getRecordingData()

View File

@@ -162,6 +162,57 @@ describe('CameraManager', () => {
const snapshot = manager.getCameraState()
expect(snapshot.target.toArray()).toEqual([0, 0, 0])
})
it('captures the active camera orientation as a serializable quaternion', () => {
manager.perspectiveCamera.position.set(5, 0, 0)
manager.perspectiveCamera.lookAt(0, 0, 0)
const { quaternion } = manager.getCameraState()
expect(quaternion).toEqual({
x: manager.perspectiveCamera.quaternion.x,
y: manager.perspectiveCamera.quaternion.y,
z: manager.perspectiveCamera.quaternion.z,
w: manager.perspectiveCamera.quaternion.w
})
expect(Object.keys(quaternion ?? {})).not.toContain('_x')
})
it('captures the active camera orientation as a serializable euler rotation', () => {
manager.perspectiveCamera.position.set(5, 0, 0)
manager.perspectiveCamera.lookAt(0, 0, 0)
const { rotation } = manager.getCameraState()
expect(rotation).toEqual({
x: manager.perspectiveCamera.rotation.x,
y: manager.perspectiveCamera.rotation.y,
z: manager.perspectiveCamera.rotation.z,
order: manager.perspectiveCamera.rotation.order
})
expect(Object.keys(rotation ?? {})).not.toContain('_x')
})
it('captures the configured perspective fov regardless of active camera', () => {
manager.perspectiveCamera.fov = 42
manager.toggleCamera('orthographic')
expect(manager.getCameraState().fov).toBe(42)
})
it('reflects the perspective aspect after a resize', () => {
manager.handleResize(800, 400)
expect(manager.getCameraState().aspect).toBe(2)
})
it('reflects the orthographic frustum bounds after a resize', () => {
manager.toggleCamera('orthographic')
manager.handleResize(800, 400)
const { frustum } = manager.getCameraState()
expect(frustum).toEqual({ left: -10, right: 10, top: 5, bottom: -5 })
})
})
describe('setControls', () => {

View File

@@ -144,6 +144,11 @@ export class CameraManager implements CameraManagerInterface {
}
getCameraState(): CameraState {
const { x, y, z, w } = this.activeCamera.quaternion
const rotation = this.activeCamera.rotation
const activeCamera = this.activeCamera as
| THREE.PerspectiveCamera
| THREE.OrthographicCamera
return {
position: this.activeCamera.position.clone(),
target: this.controls?.target.clone() || new THREE.Vector3(),
@@ -151,7 +156,24 @@ export class CameraManager implements CameraManagerInterface {
this.activeCamera instanceof THREE.OrthographicCamera
? this.activeCamera.zoom
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
cameraType: this.getCurrentCameraType()
cameraType: this.getCurrentCameraType(),
quaternion: { x, y, z, w },
rotation: {
x: rotation.x,
y: rotation.y,
z: rotation.z,
order: rotation.order
},
fov: this.perspectiveCamera.fov,
aspect: this.perspectiveCamera.aspect,
near: activeCamera.near,
far: activeCamera.far,
frustum: {
left: this.orthographicCamera.left,
right: this.orthographicCamera.right,
top: this.orthographicCamera.top,
bottom: this.orthographicCamera.bottom
}
}
}

View File

@@ -314,6 +314,38 @@ describe('GizmoManager', () => {
})
})
describe('getModelInfo', () => {
it('returns the full transform payload for the target object', () => {
manager.init()
const model = new THREE.Object3D()
model.name = 'my-model'
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(4, 5, 6)
manager.setupForModel(model)
const info = manager.getModelInfo()
expect(info).not.toBeNull()
expect(info!.uuid).toBe(model.uuid)
expect(info!.name).toBe('my-model')
expect(info!.type).toBe('Object3D')
expect(info!.position).toEqual({ x: 1, y: 2, z: 3 })
expect(info!.rotation.x).toBeCloseTo(0.1)
expect(info!.rotation.order).toBe(model.rotation.order)
expect(info!.quaternion.w).toBeCloseTo(model.quaternion.w)
expect(info!.scale).toEqual({ x: 4, y: 5, z: 6 })
expect(info!.up).toEqual({ x: 0, y: 1, z: 0 })
expect(info!.visible).toBe(true)
expect(info!.matrix).toHaveLength(16)
})
it('returns null when there is no target', () => {
manager.init()
expect(manager.getModelInfo()).toBeNull()
})
})
describe('removeFromScene / ensureHelperInScene', () => {
it('removes helper from scene', () => {
manager.init()

View File

@@ -3,7 +3,7 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode } from './interfaces'
import type { GizmoMode, ModelTransform } from './interfaces'
export class GizmoManager {
private transformControls: TransformControls | null = null
@@ -215,6 +215,48 @@ export class GizmoManager {
}
}
getModelInfo(): ModelTransform | null {
const object = this.targetObject
if (!object) return null
object.updateMatrix()
return {
uuid: object.uuid,
name: object.name,
type: object.type,
position: {
x: object.position.x,
y: object.position.y,
z: object.position.z
},
rotation: {
x: object.rotation.x,
y: object.rotation.y,
z: object.rotation.z,
order: object.rotation.order
},
quaternion: {
x: object.quaternion.x,
y: object.quaternion.y,
z: object.quaternion.z,
w: object.quaternion.w
},
scale: {
x: object.scale.x,
y: object.scale.y,
z: object.scale.z
},
up: {
x: object.up.x,
y: object.up.y,
z: object.up.z
},
visible: object.visible,
matrix: object.matrix.toArray()
}
}
dispose(): void {
if (this.transformControls) {
const helper = this.transformControls.getHelper()

View File

@@ -25,6 +25,7 @@ import type {
Load3DOptions,
LoadModelOptions,
MaterialMode,
ModelTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
@@ -914,6 +915,10 @@ class Load3d {
return this.gizmoManager.getTransform()
}
public getModelInfo(): ModelTransform | null {
return this.gizmoManager.getModelInfo()
}
public fitToViewer(): void {
this.modelManager.fitToViewer()
this.forceRender()

View File

@@ -15,7 +15,7 @@ export interface ModelLoadContext {
readonly materialMode: MaterialMode
}
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**

View File

@@ -15,18 +15,62 @@ export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'
interface CameraQuaternion {
x: number
y: number
z: number
w: number
}
interface CameraRotation {
x: number
y: number
z: number
order: string
}
interface CameraFrustum {
left: number
right: number
top: number
bottom: number
}
export interface CameraState {
position: THREE.Vector3
target: THREE.Vector3
zoom: number
cameraType: CameraType
quaternion?: CameraQuaternion
rotation?: CameraRotation
fov?: number
aspect?: number
near?: number
far?: number
frustum?: CameraFrustum
}
export interface ModelTransform {
uuid: string
name: string
type: string
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number; order: string }
quaternion: { x: number; y: number; z: number; w: number }
scale: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
visible: boolean
matrix: number[]
}
export type ModelInfo = ModelTransform[]
export interface SceneConfig {
showGrid: boolean
backgroundColor: string
backgroundImage?: string
backgroundRenderMode?: BackgroundRenderModeType
models?: ModelInfo
}
export type GizmoMode = 'translate' | 'rotate' | 'scale'

View File

@@ -1,7 +1,6 @@
import { toString } from 'es-toolkit/compat'
import { toValue } from 'vue'
import { isMiddleButtonEvent, isMiddlePointerInput } from '@/base/pointerUtils'
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
@@ -1988,7 +1987,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** Prevents default for middle-click auxclick only. */
_preventMiddleAuxClick(e: MouseEvent): void {
if (isMiddleButtonEvent(e)) e.preventDefault()
if (e.button === 1) e.preventDefault()
}
/** Captures an event and prevents default - returns true. */
@@ -2314,7 +2313,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// left button mouse / single finger
if (e.button === 0 && !pointer.isDouble) {
this._processPrimaryButton(e, node)
} else if (isMiddlePointerInput(e)) {
} else if (e.button === 1) {
this._processMiddleButton(e, node)
} else if (
(e.button === 2 || pointer.isDouble) &&
@@ -3865,7 +3864,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this
)
}
} else if (isMiddleButtonEvent(e)) {
} else if (e.button === 1) {
// middle button
this.dirty_canvas = true
this.dragging_canvas = false

View File

@@ -1,4 +1,3 @@
import { isMiddleButtonHeld } from '@/base/pointerUtils'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
/**
@@ -72,7 +71,7 @@ export class InputIndicators implements Disposable {
private _onPointerDownOrMove = this.onPointerDownOrMove.bind(this)
onPointerDownOrMove(e: MouseEvent): void {
this.mouse0Down = (e.buttons & 1) === 1
this.mouse1Down = isMiddleButtonHeld(e)
this.mouse1Down = (e.buttons & 4) === 4
this.mouse2Down = (e.buttons & 2) === 2
this.x = e.clientX

View File

@@ -138,6 +138,8 @@
"hideLeftPanel": "Hide left panel",
"showRightPanel": "Show right panel",
"hideRightPanel": "Hide right panel",
"maximizeDialog": "Maximize dialog",
"restoreDialog": "Restore dialog",
"or": "or",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
@@ -3773,6 +3775,191 @@
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
}
},
"runtimeErrors": {
"execution_failed": {
"title": "Execution failed",
"message": "Node threw an error during execution.",
"itemLabel": "{nodeName}",
"toastTitle": "{nodeName} failed",
"toastMessage": "This node threw an error during execution. Check its inputs or try a different configuration."
},
"image_not_loaded": {
"title": "Image not loaded",
"message": "The system couldn't load this image.",
"itemLabel": "{nodeName}",
"toastTitle": "Input image couldn't be loaded",
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
},
"out_of_memory": {
"title": "Generation failed",
"message": "Not enough GPU memory. Try reducing image resolution or batch size and run again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation failed",
"toastMessage": "Not enough GPU memory. Try reducing image resolution or batch size and run again."
},
"content_blocked": {
"title": "Content blocked",
"message": "This request was blocked by the content moderation system. Try changing the prompt or inputs.",
"itemLabel": "{nodeName}",
"toastTitle": "Content blocked",
"toastMessage": "This request was blocked by the content moderation system. Try changing the prompt or inputs."
},
"access_required": {
"title": "Access required",
"message": "This run requires access that is not available for the current account.",
"itemLabel": "{nodeName}",
"toastTitle": "Access required",
"toastMessage": "This run requires access that is not available for the current account."
},
"model_access_error": {
"title": "Model access required",
"message": "One or more required models could not be accessed.",
"itemLabel": "{nodeName}",
"toastTitle": "Model access required",
"toastMessage": "One or more required models could not be accessed."
},
"invalid_clip_input": {
"title": "Invalid CLIP input",
"message": "The CLIP input is missing or invalid. Check the connected checkpoint or CLIP loader.",
"itemLabel": "{nodeName}",
"toastTitle": "Invalid CLIP input",
"toastMessage": "{nodeName} has a missing or invalid CLIP input."
},
"invalid_prompt": {
"title": "Prompt is empty",
"message": "Enter a prompt before running this workflow.",
"itemLabel": "{nodeName}",
"toastTitle": "Prompt is empty",
"toastMessage": "Enter a prompt before running this workflow."
},
"invalid_workflow_request": {
"title": "Invalid workflow request",
"message": "The workflow request is invalid. Check the workflow and try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Invalid workflow request",
"toastMessage": "The workflow request is invalid. Check the workflow and try again."
},
"insufficient_credits": {
"title": "Insufficient credits",
"message": "Add credits to your account to use this node.",
"itemLabel": "{nodeName}",
"toastTitle": "Insufficient credits",
"toastMessage": "Add credits to your account to use this node."
},
"workspace_insufficient_credits": {
"title": "Insufficient credits",
"message": "Add credits to your workspace to continue.",
"itemLabel": "{nodeName}",
"toastTitle": "Insufficient credits",
"toastMessage": "Add credits to your workspace to continue."
},
"subscription_required": {
"title": "Subscription required",
"message": "Subscribe to a plan to continue running this workflow.",
"itemLabel": "{nodeName}",
"toastTitle": "Subscription required",
"toastMessage": "Subscribe to a plan to continue running this workflow."
},
"subscription_upgrade_required": {
"title": "Subscription upgrade required",
"message": "Upgrade your subscription to use the private models in this workflow.",
"details": "Private models require a subscription upgrade: {modelNames}",
"itemLabel": "{nodeName}",
"toastTitle": "Subscription upgrade required",
"toastMessage": "Upgrade your subscription to use these private models: {modelNames}."
},
"model_download_failed": {
"title": "Model download failed",
"message": "A model could not be downloaded. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Model download failed",
"toastMessage": "A model could not be downloaded. Try again."
},
"unexpected_service_error": {
"title": "Service error",
"message": "The service encountered an unexpected error. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Service error",
"toastMessage": "The service encountered an unexpected error. Try again."
},
"request_failed": {
"title": "Request failed",
"message": "The request failed before the run could complete. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Request failed",
"toastMessage": "The request failed before the run could complete. Try again."
},
"run_start_failed": {
"title": "Run could not start",
"message": "The run could not be started. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Run could not start",
"toastMessage": "The run could not be started. Try again."
},
"run_ended_unexpectedly": {
"title": "Run ended unexpectedly",
"message": "The run ended unexpectedly. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Run ended unexpectedly",
"toastMessage": "The run ended unexpectedly. Try again."
},
"sign_in_required": {
"title": "Sign in required",
"message": "Partner nodes require a Comfy account. Sign in to continue.",
"itemLabel": "{nodeName}",
"toastTitle": "Sign in to use this node",
"toastMessage": "Partner nodes require a Comfy account. Sign in to continue."
},
"rate_limited": {
"title": "Servers are busy",
"message": "High demand right now. Try again in a moment.",
"itemLabel": "{nodeName}",
"toastTitle": "Servers are busy",
"toastMessage": "High demand right now. Try again in a moment."
},
"timeout": {
"title": "Generation timed out",
"message": "This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation timed out",
"toastMessage": "This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again."
},
"generation_stalled": {
"title": "Generation stalled",
"message": "This workflow stopped making progress. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation stalled",
"toastMessage": "This workflow stopped making progress. Try running it again."
},
"preprocessing_failed": {
"title": "Preparation failed",
"message": "The workflow could not be prepared. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Preparation failed",
"toastMessage": "The workflow could not be prepared. Try running it again."
},
"preprocessing_timeout": {
"title": "Preparation timed out",
"message": "The workflow took too long to prepare. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Preparation timed out",
"toastMessage": "The workflow took too long to prepare. Try running it again."
},
"server_crashed": {
"title": "Server crashed",
"message": "The server stopped while running this workflow. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Server crashed",
"toastMessage": "The server stopped while running this workflow. Try again."
},
"server_busy": {
"title": "Servers are busy",
"message": "The servers are busy right now. Try again in a moment.",
"itemLabel": "{nodeName}",
"toastTitle": "Servers are busy",
"toastMessage": "The servers are busy right now. Try again in a moment."
}
},
"promptErrors": {
"prompt_no_outputs": {
"title": "Prompt has no outputs",
@@ -3797,15 +3984,6 @@
"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

@@ -107,6 +107,12 @@ app.directive('tooltip', Tooltip)
app
.use(router)
.use(PrimeVue, {
zIndex: {
modal: 1800,
overlay: 1800,
menu: 1800,
tooltip: 1800
},
theme: {
preset: ComfyUIPreset,
options: {

View File

@@ -236,5 +236,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
['optical_flow', 'OpticalFlowLoader', 'model_name'],
// ---- WanVideo (ComfyUI-WanVideoWrapper) ----
['loras', 'WanVideoLoraSelect', 'lora']
['loras', 'WanVideoLoraSelect', 'lora'],
// ---- LTX-Video IC-LoRA (ComfyUI-LTXVideo) ----
['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']
] as const satisfies ReadonlyArray<readonly [string, string, string]>

View File

@@ -0,0 +1,40 @@
import { t, te } from '@/i18n'
// Shared i18n helpers for error catalog resolvers. These preserve the raw API
// message/details as fallbacks when a catalog key is not available. Keep this
// module folder-internal so UI code only consumes resolved display fields.
export interface ErrorResolveContext {
isCloud?: boolean
nodeDisplayName?: string
}
export type CatalogParams = Record<string, string | number>
export function translateCatalogMessage(
key: string,
fallback: string,
params?: CatalogParams
): string {
if (te(key)) return params ? t(key, params) : t(key)
if (!params) return fallback
return fallback.replace(/\{(\w+)\}/g, (match, paramName) =>
params[paramName] === undefined ? match : String(params[paramName])
)
}
export 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
}
export function normalizeNodeName(nodeDisplayName: string | undefined): string {
return (
nodeDisplayName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}

View File

@@ -0,0 +1,32 @@
// FE-resolved catalog IDs that either normalize multiple sources or do not map
// 1:1 to an API error type. Simple validation mappings stay with the validation
// resolver.
export const MISSING_CONNECTION_CATALOG_ID = 'missing_connection'
export const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
export const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
export const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'
export const CONTENT_BLOCKED_CATALOG_ID = 'content_blocked'
export const ACCESS_REQUIRED_CATALOG_ID = 'access_required'
export const MODEL_ACCESS_ERROR_CATALOG_ID = 'model_access_error'
export const INVALID_CLIP_INPUT_CATALOG_ID = 'invalid_clip_input'
export const INVALID_PROMPT_CATALOG_ID = 'invalid_prompt'
export const INVALID_WORKFLOW_REQUEST_CATALOG_ID = 'invalid_workflow_request'
export const INSUFFICIENT_CREDITS_CATALOG_ID = 'insufficient_credits'
export const WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID =
'workspace_insufficient_credits'
export const SUBSCRIPTION_REQUIRED_CATALOG_ID = 'subscription_required'
export const SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID =
'subscription_upgrade_required'
export const MODEL_DOWNLOAD_FAILED_CATALOG_ID = 'model_download_failed'
export const UNEXPECTED_SERVICE_ERROR_CATALOG_ID = 'unexpected_service_error'
export const REQUEST_FAILED_CATALOG_ID = 'request_failed'
export const RUN_START_FAILED_CATALOG_ID = 'run_start_failed'
export const RUN_ENDED_UNEXPECTEDLY_CATALOG_ID = 'run_ended_unexpectedly'
export const SIGN_IN_REQUIRED_CATALOG_ID = 'sign_in_required'
export const RATE_LIMITED_CATALOG_ID = 'rate_limited'
export const TIMEOUT_CATALOG_ID = 'timeout'
export const GENERATION_STALLED_CATALOG_ID = 'generation_stalled'
export const PREPROCESSING_FAILED_CATALOG_ID = 'preprocessing_failed'
export const PREPROCESSING_TIMEOUT_CATALOG_ID = 'preprocessing_timeout'
export const SERVER_CRASHED_CATALOG_ID = 'server_crashed'
export const SERVER_BUSY_CATALOG_ID = 'server_busy'

View File

@@ -5,6 +5,7 @@ import {
resolveRunErrorMessage
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { i18n } from '@/i18n'
function nodeValidationError(
@@ -36,6 +37,24 @@ function requiredInputMissing(inputName?: string): NodeValidationError {
}
}
function executionError(
exceptionType: string,
exceptionMessage: string
): ExecutionErrorWsMessage {
return {
prompt_id: 'prompt-1',
timestamp: Date.now(),
node_id: '1',
node_type: 'KSampler',
executed: [],
exception_type: exceptionType,
exception_message: exceptionMessage,
traceback: [],
current_inputs: {},
current_outputs: {}
}
}
describe('errorMessageResolver', () => {
it('resolves required_input_missing to missing connection display copy', () => {
const result = resolveRunErrorMessage({
@@ -533,7 +552,8 @@ describe('errorMessageResolver', () => {
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayMessage:
'Not enough GPU memory. Try reducing complexity and run again. No credits charged.'
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
displayDetails: 'Workflow execution failed'
})
expect(
@@ -550,7 +570,8 @@ describe('errorMessageResolver', () => {
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayMessage:
'Not enough GPU memory. Try reducing complexity and run again.'
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
displayDetails: 'Workflow execution failed'
})
expect(
@@ -566,7 +587,8 @@ describe('errorMessageResolver', () => {
).toEqual({
catalogId: 'image_not_loaded',
displayTitle: 'Image not loaded',
displayMessage: "The system couldn't load this image."
displayMessage: "The system couldn't load this image.",
displayDetails: 'Failed to validate images'
})
expect(
@@ -586,6 +608,704 @@ describe('errorMessageResolver', () => {
})
})
it('resolves targeted runtime execution errors', () => {
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'KSampler',
error: executionError(
'torch.OutOfMemoryError',
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.'
)
})
).toEqual({
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
displayDetails:
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
displayItemLabel: 'KSampler',
toastTitle: 'Generation failed',
toastMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'Load Image',
error: executionError('ImageDownloadError', 'Failed to validate images')
})
).toMatchObject({
catalogId: 'image_not_loaded',
displayTitle: 'Image not loaded',
displayMessage: "The system couldn't load this image.",
displayItemLabel: 'Load Image',
toastTitle: "Input image couldn't be loaded",
toastMessage:
"The image for Load Image couldn't be loaded. Try adding it again."
})
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'Load Image',
error: executionError(
'IsADirectoryError',
"[Errno 21] Is a directory: '/app/comfyui/input'"
)
})
).toMatchObject({
catalogId: 'image_not_loaded',
displayTitle: 'Image not loaded',
displayMessage: "The system couldn't load this image.",
displayItemLabel: 'Load Image'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'File Reader',
error: executionError(
'RuntimeError',
"[Errno 21] Is a directory: '/tmp/not-an-input-image'"
)
})
).toMatchObject({
catalogId: 'execution_failed',
displayTitle: 'Execution failed'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'CLIP Text Encode',
error: executionError(
'RuntimeError',
'ERROR: clip input is invalid: None\n\nIf the clip is from a checkpoint loader node your checkpoint does not contain a valid clip or text encoder model.'
)
})
).toMatchObject({
catalogId: 'invalid_clip_input',
displayTitle: 'Invalid CLIP input',
displayMessage:
'The CLIP input is missing or invalid. Check the connected checkpoint or CLIP loader.',
displayItemLabel: 'CLIP Text Encode',
toastMessage: 'CLIP Text Encode has a missing or invalid CLIP input.'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'KSampler',
error: executionError(
'OOMError',
'Workflow execution failed due to insufficient memory (OOM). Try reducing image resolution or batch size.'
)
})
).toMatchObject({
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
displayItemLabel: 'KSampler'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'KSampler',
error: executionError(
'RuntimeError',
'CUDA out of memory. Tried to allocate 6.00 GiB. GPU 0 has 2.00 GiB free.'
)
})
).toMatchObject({
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayDetails:
'CUDA out of memory. Tried to allocate 6.00 GiB. GPU 0 has 2.00 GiB free.'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'KSampler',
error: executionError('RuntimeError', 'GPU out of memory')
})
).toMatchObject({
catalogId: 'out_of_memory',
displayMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
displayDetails: 'GPU out of memory'
})
})
it.for([
{
type: 'InsufficientFundsError',
message:
'Payment Required: Please add credits to your account to use this node.',
expected: {
catalogId: 'insufficient_credits',
displayTitle: 'Insufficient credits',
displayMessage: 'Add credits to your account to use this node.'
}
},
{
type: 'InsufficientFundsError',
message:
'Payment Required: Please add credits to your workspace to continue.',
expected: {
catalogId: 'workspace_insufficient_credits',
displayTitle: 'Insufficient credits',
displayMessage: 'Add credits to your workspace to continue.'
}
},
{
type: 'InactiveSubscriptionError',
message:
'User has no active subscription. Please subscribe to a plan to continue.',
expected: {
catalogId: 'subscription_required',
displayTitle: 'Subscription required',
displayMessage: 'Subscribe to a plan to continue running this workflow.'
}
},
{
type: 'RuntimeError',
message:
'the following private models require a subscription upgrade: Skullgirls_Cerebella.safetensors',
expected: {
catalogId: 'subscription_upgrade_required',
displayTitle: 'Subscription upgrade required',
displayMessage:
'Upgrade your subscription to use the private models in this workflow.',
displayDetails:
'Private models require a subscription upgrade: Skullgirls_Cerebella.safetensors',
toastMessage:
'Upgrade your subscription to use these private models: Skullgirls_Cerebella.safetensors.'
}
},
{
type: 'RuntimeError',
message: 'Unauthorized: Please login first to use this node.',
expected: {
catalogId: 'sign_in_required',
displayTitle: 'Sign in required',
displayMessage:
'Partner nodes require a Comfy account. Sign in to continue.'
}
},
{
type: 'RuntimeError',
message:
'Rate Limit Exceeded: The server returned 429 after all retry attempts. Please wait and try again.',
expected: {
catalogId: 'rate_limited',
displayTitle: 'Servers are busy',
displayMessage: 'High demand right now. Try again in a moment.'
}
}
])(
'resolves $type runtime execution errors by stable copy',
({ type, message, expected }) => {
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'API Node',
error: executionError(type, message)
})
).toMatchObject({
...expected,
displayItemLabel: 'API Node'
})
}
)
it.for([
{
type: 'ServiceError',
message: 'ServiceError: Job execution time exceeded maximum limit',
expected: {
catalogId: 'timeout',
displayTitle: 'Generation timed out',
displayMessage:
'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: Job went too long without making any progress',
expected: {
catalogId: 'generation_stalled',
displayTitle: 'Generation stalled',
displayMessage:
'This workflow stopped making progress. Try running it again.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: Job has stagnated',
expected: {
catalogId: 'generation_stalled',
displayTitle: 'Generation stalled',
displayMessage:
'This workflow stopped making progress. Try running it again.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: RIP to the server your workflow was running on.',
expected: {
catalogId: 'server_crashed',
displayTitle: 'Server crashed',
displayMessage:
'The server stopped while running this workflow. Try again.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: Executor is busy with another job',
expected: {
catalogId: 'server_busy',
displayTitle: 'Servers are busy',
displayMessage: 'The servers are busy right now. Try again in a moment.'
}
},
{
type: 'DispatcherError',
message: 'DispatcherError: Preprocessing timed out',
expected: {
catalogId: 'preprocessing_timeout',
displayTitle: 'Preparation timed out',
displayMessage:
'The workflow took too long to prepare. Try running it again.'
}
},
{
type: 'DispatcherError',
message: 'DispatcherError: Preprocessing failed',
expected: {
catalogId: 'preprocessing_failed',
displayTitle: 'Preparation failed',
displayMessage:
'The workflow could not be prepared. Try running it again.'
}
},
{
type: 'DispatcherError',
message: 'DispatcherError: Preprocessing failed: input archive missing',
expected: {
catalogId: 'preprocessing_failed',
displayTitle: 'Preparation failed',
displayMessage:
'The workflow could not be prepared. Try running it again.',
displayDetails: 'Preprocessing failed: input archive missing'
}
},
{
type: 'AccessRequired',
message:
'AccessRequired: This run requires access that is not available for the current account.',
expected: {
catalogId: 'access_required',
displayTitle: 'Access required',
displayMessage:
'This run requires access that is not available for the current account.'
}
},
{
type: 'ModelAccessError',
message:
'ModelAccessError: One or more required models could not be accessed.',
expected: {
catalogId: 'model_access_error',
displayTitle: 'Model access required',
displayMessage: 'One or more required models could not be accessed.'
}
},
{
type: 'ValidationError',
message:
"ValidationError: Field 'prompt' cannot be shorter than 1 characters; was 0 characters long.",
expected: {
catalogId: 'invalid_prompt',
displayTitle: 'Prompt is empty',
displayMessage: 'Enter a prompt before running this workflow.'
}
},
{
type: 'ValidationError',
message: "ValidationError: Field 'prompt' cannot be empty.",
expected: {
catalogId: 'invalid_prompt',
displayTitle: 'Prompt is empty',
displayMessage: 'Enter a prompt before running this workflow.'
}
},
{
type: 'ValidationError',
message: 'ValidationError: The workflow request is invalid.',
expected: {
catalogId: 'invalid_workflow_request',
displayTitle: 'Invalid workflow request',
displayMessage:
'The workflow request is invalid. Check the workflow and try again.'
}
},
{
type: 'ValidationError',
message: 'ValidationError: Invalid job: missing workflow',
expected: {
catalogId: 'invalid_workflow_request',
displayTitle: 'Invalid workflow request',
displayMessage:
'The workflow request is invalid. Check the workflow and try again.'
}
},
{
type: 'ValidationError',
message: "ValidationError: Invalid workflow: missing 'prompt' field",
expected: {
catalogId: 'invalid_workflow_request',
displayTitle: 'Invalid workflow request',
displayMessage:
'The workflow request is invalid. Check the workflow and try again.'
}
},
{
type: 'ValidationError',
message:
"ValidationError: Invalid workflow: 'prompt' field must be an object",
expected: {
catalogId: 'invalid_workflow_request',
displayTitle: 'Invalid workflow request',
displayMessage:
'The workflow request is invalid. Check the workflow and try again.'
}
},
{
type: 'ModelDownloadError',
message:
'ModelDownloadError: the following private models require a subscription upgrade: Skullgirls_Cerebella.safetensors, alex_ahad_style_ponyxl.safetensors',
expected: {
catalogId: 'subscription_upgrade_required',
displayTitle: 'Subscription upgrade required',
displayDetails:
'Private models require a subscription upgrade: Skullgirls_Cerebella.safetensors, alex_ahad_style_ponyxl.safetensors'
}
},
{
type: 'PanicError',
message:
'PanicError: internal error during model download: runtime error: invalid memory address',
expected: {
catalogId: 'model_download_failed',
displayTitle: 'Model download failed',
displayMessage: 'A model could not be downloaded. Try again.'
}
},
{
type: 'PanicError',
message: 'PanicError: internal error during model download: boom',
expected: {
catalogId: 'model_download_failed',
displayTitle: 'Model download failed',
displayMessage: 'A model could not be downloaded. Try again.'
}
},
{
type: 'PanicError',
message: 'PanicError: panic during job execution: boom',
expected: {
catalogId: 'run_ended_unexpectedly',
displayTitle: 'Run ended unexpectedly',
displayMessage: 'The run ended unexpectedly. Try again.'
}
},
{
type: 'UnexpectedServiceError',
message: 'UnexpectedServiceError: Unexpected service error.',
expected: {
catalogId: 'unexpected_service_error',
displayTitle: 'Service error',
displayMessage:
'The service encountered an unexpected error. Try again.'
}
},
{
type: 'RequestError',
message:
'RequestError: The request failed before the run could complete.',
expected: {
catalogId: 'request_failed',
displayTitle: 'Request failed',
displayMessage:
'The request failed before the run could complete. Try again.'
}
},
{
type: 'PreprocessingTimeout',
message: 'PreprocessingTimeout: Preprocessing timed out.',
expected: {
catalogId: 'preprocessing_timeout',
displayTitle: 'Preparation timed out',
displayMessage:
'The workflow took too long to prepare. Try running it again.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: The run could not be started.',
expected: {
catalogId: 'run_start_failed',
displayTitle: 'Run could not start',
displayMessage: 'The run could not be started. Try again.'
}
},
{
type: 'WebSocketError',
message: 'WebSocketError: Failed to start WebSocket client: EOF',
expected: {
catalogId: 'run_start_failed',
displayTitle: 'Run could not start',
displayMessage: 'The run could not be started. Try again.',
displayDetails: 'Failed to start WebSocket client: EOF'
}
},
{
type: 'ServiceError',
message:
'ServiceError: Failed to send prompt request: connection refused',
expected: {
catalogId: 'request_failed',
displayTitle: 'Request failed',
displayMessage:
'The request failed before the run could complete. Try again.',
displayDetails: 'Failed to send prompt request: connection refused'
}
},
{
type: 'ServiceError',
message:
'ServiceError: Failed to complete preparation: transition failed',
expected: {
catalogId: 'preprocessing_failed',
displayTitle: 'Preparation failed',
displayMessage:
'The workflow could not be prepared. Try running it again.',
displayDetails: 'Failed to complete preparation: transition failed'
}
},
{
type: 'ServiceError',
message: 'ServiceError: The run ended unexpectedly.',
expected: {
catalogId: 'run_ended_unexpectedly',
displayTitle: 'Run ended unexpectedly',
displayMessage: 'The run ended unexpectedly. Try again.'
}
},
{
type: 'Exception',
message: 'Exception: Servers are busy. Please try again later.',
expected: {
catalogId: 'server_busy',
displayTitle: 'Servers are busy',
displayMessage: 'The servers are busy right now. Try again in a moment.'
}
},
{
type: 'WebSocketError',
message:
'WebSocketError: Polling aborted due to error: API Error: {"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation."}',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs.'
}
},
{
type: 'Exception',
message: 'Exception: Generated video rejected by content moderation.',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs.'
}
},
{
type: 'Exception',
message: 'Exception: Prompt or Initial Image failed the safety checks.',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs.'
}
},
{
type: 'ValueError',
message:
'ValueError: The generated image was flagged for content policy violation.',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs.'
}
},
{
type: 'Exception',
message:
"Exception: Content filtered by Google's Responsible AI practices: safety (1 video filtered.)",
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs.'
}
},
{
type: 'Exception',
message:
"Exception: Content blocked by Google's Responsible AI filters (1 video filtered).",
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs.'
}
},
{
type: 'Exception',
message: 'Exception: Generated content was rejected by a safety check.',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs.'
}
}
])(
'resolves non-node-scoped runtime failures',
({ type, message, expected }) => {
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type,
message,
details: ''
}
})
).toMatchObject(expected)
}
)
it('resolves timeout copy without credit copy', () => {
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'KSampler',
error: executionError(
'ServiceError',
'Job execution time exceeded maximum limit'
)
})
).toMatchObject({
catalogId: 'timeout',
displayMessage:
'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.',
toastMessage:
'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.'
})
})
it('does not over-match runtime error lookalikes', () => {
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'RequestError',
message:
'RequestError: Failed to send prompt request: request returned error status 400: {"error":{"type":"prompt_outputs_failed_validation"}}',
details: ''
}
})
).toEqual({})
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'RequestError',
message:
'RequestError: Failed to send prompt request: renderer template {node}',
details: ''
}
})
).toMatchObject({
catalogId: 'request_failed',
displayTitle: 'Request failed',
displayDetails: 'Failed to send prompt request: renderer template {node}'
})
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'Exception',
message:
'Exception: Debug output mentioned the content moderation system, but no content was blocked.',
details: ''
}
})
).toEqual({})
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'ModelDownloadError',
message:
'ModelDownloadError: the following private models require a subscription upgrade:',
details: ''
}
})
).toEqual({})
})
it('resolves unknown node execution errors to the general runtime fallback', () => {
expect(
resolveRunErrorMessage({
kind: 'execution',
nodeDisplayName: 'KSampler',
error: executionError(
'RuntimeError',
'mat1 and mat2 shapes cannot be multiplied'
)
})
).toEqual({
catalogId: 'execution_failed',
displayTitle: 'Execution failed',
displayMessage: 'Node threw an error during execution.',
displayItemLabel: 'KSampler',
toastTitle: 'KSampler failed',
toastMessage:
'This node threw an error during execution. Check its inputs or try a different configuration.'
})
})
it('resolves missing error group display copy', () => {
expect(
resolveMissingErrorMessage({

View File

@@ -1,514 +1,13 @@
import type {
MissingErrorMessageSource,
NodeValidationError,
ResolvedErrorMessage,
ResolvedMissingErrorMessage,
RunErrorMessageSource
} from './types'
import { st, t, te } from '@/i18n'
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
const REQUIRED_INPUT_MISSING_CATALOG_ID = 'missing_connection'
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',
'missing_node_type',
'prompt_outputs_failed_validation'
])
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
import { resolvePromptErrorMessage } from './promptErrorResolver'
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
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?: CatalogParams
): string {
if (te(key)) return params ? t(key, params) : t(key)
if (!params) return fallback
return fallback.replace(/\{(\w+)\}/g, (match, paramName) =>
params[paramName] === undefined ? match : String(params[paramName])
)
}
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() ||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
inputName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
)
}
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 (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
context,
'image_not_loaded',
IMAGE_NOT_LOADED_VALIDATION_RULE
)
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) return {}
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 =
error.type === 'server_error'
? context.isCloud
? 'server_error_cloud'
: 'server_error_local'
: error.type
return {
displayTitle: translateCatalogMessage(
`errorCatalog.promptErrors.${errorTypeKey}.title`,
error.message
),
displayMessage: st(
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
error.message
)
}
}
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function translateMissingModelOverlayMessage(count: number): string {
const translated = t('errorOverlay.missingModels', { count }, count)
return translated === 'errorOverlay.missingModels'
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
: translated
}
export function resolveMissingErrorMessage(
source: MissingErrorMessageSource
): ResolvedMissingErrorMessage {
switch (source.kind) {
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
}
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
}
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
),
displayMessage: translateMissingModelOverlayMessage(source.count)
}
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
}
}
}
// Public facade for error catalog resolution. Source-specific resolver modules
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export function resolveRunErrorMessage(
source: RunErrorMessageSource
@@ -522,5 +21,9 @@ export function resolveRunErrorMessage(
return resolvePromptErrorMessage(source.error, {
isCloud: source.isCloud
})
case 'execution':
return resolveExecutionErrorMessage(source.error, {
nodeDisplayName: source.nodeDisplayName
})
}
}

View File

@@ -0,0 +1,39 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds'
import type { ErrorResolveContext } from './catalogI18n'
import { resolveRuntimeCatalogCopy } from './runtimeErrorCopy'
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
type ExecutionErrorResolveContext = Pick<ErrorResolveContext, 'nodeDisplayName'>
// Resolves node-scoped runtime failures while preserving raw API fields.
export function resolveExecutionErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'execution' }>['error'],
context: ExecutionErrorResolveContext
): ResolvedErrorMessage {
const exceptionMessage = error.exception_message.trim()
const match = resolveRuntimeCatalogMatch({
exceptionType: error.exception_type,
exceptionMessage
})
if (!match) {
return resolveRuntimeCatalogCopy(
EXECUTION_FAILED_CATALOG_ID,
error.exception_message,
context,
{ includeItemLabel: true }
)
}
return resolveRuntimeCatalogCopy(
match.catalogId,
error.exception_message,
context,
{
includeItemLabel: true,
params: match.params,
detailsFallback: match.detailsFallback
}
)
}

View File

@@ -0,0 +1,78 @@
import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { st, t } from '@/i18n'
// Resolves pre-run missing-resource groups (nodes, models, media, swaps). These
// are grouped catalog messages rather than individual execution error items.
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function translateMissingModelOverlayMessage(count: number): string {
const translated = t('errorOverlay.missingModels', { count }, count)
return translated === 'errorOverlay.missingModels'
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
: translated
}
export function resolveMissingErrorMessage(
source: MissingErrorMessageSource
): ResolvedMissingErrorMessage {
switch (source.kind) {
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
}
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
}
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
),
displayMessage: translateMissingModelOverlayMessage(source.count)
}
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
}
}
}

View File

@@ -0,0 +1,72 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type { ErrorResolveContext } from './catalogI18n'
import { translateCatalogMessage } from './catalogI18n'
import { resolveRuntimeCatalogCopy } from './runtimeErrorCopy'
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
import { st } from '@/i18n'
// Resolves prompt-level errors and non-node-scoped failures before falling
// back to prompt-specific catalog keys.
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error',
'missing_node_type',
'prompt_outputs_failed_validation'
])
function getPromptExceptionMessage(
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error']
): string {
const message = error.message.trim()
const prefixedType = `${error.type}: `
return message.startsWith(prefixedType)
? message.slice(prefixedType.length).trim()
: message
}
export function resolvePromptErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error'],
context: ErrorResolveContext
): ResolvedErrorMessage {
const promptExceptionMessage = getPromptExceptionMessage(error)
const runtimeMatch = resolveRuntimeCatalogMatch({
exceptionType: error.type,
exceptionMessage: promptExceptionMessage
})
if (runtimeMatch) {
// Leave toast copy to node-scoped errors where a node-specific
// action/message is safe.
return resolveRuntimeCatalogCopy(
runtimeMatch.catalogId,
promptExceptionMessage || error.message,
context,
{
includeToast: false,
params: runtimeMatch.params,
detailsFallback: runtimeMatch.detailsFallback
}
)
}
if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {}
const errorTypeKey =
error.type === 'server_error'
? context.isCloud
? 'server_error_cloud'
: 'server_error_local'
: error.type
return {
displayTitle: translateCatalogMessage(
`errorCatalog.promptErrors.${errorTypeKey}.title`,
error.message
),
displayMessage: st(
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
error.message
)
}
}

View File

@@ -0,0 +1,53 @@
import type { ResolvedErrorMessage } from './types'
import {
normalizeNodeName,
translateCatalogMessage,
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
// Builds resolved display fields while callers keep the raw API message/details
// on the ErrorItem.
export function resolveRuntimeCatalogCopy(
catalogId: string,
fallbackMessage: string,
context: ErrorResolveContext,
options: {
includeItemLabel?: boolean
includeToast?: boolean
params?: CatalogParams
detailsFallback?: string
} = {}
): ResolvedErrorMessage {
const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}`
const nodeName = normalizeNodeName(context.nodeDisplayName)
const params = { nodeName, ...options.params }
const resolveMessage = (suffix: string, fallback = fallbackMessage) =>
translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params)
const displayMessage = resolveMessage('message')
const result: ResolvedErrorMessage = {
catalogId,
displayTitle: resolveMessage('title'),
displayMessage
}
if (options.includeToast !== false) {
result.toastTitle = resolveMessage('toastTitle')
result.toastMessage = resolveMessage('toastMessage')
}
const displayDetails = translateOptionalCatalogMessage(
`${keyPrefix}.details`,
options.detailsFallback,
params
)
if (displayDetails) result.displayDetails = displayDetails
if (options.includeItemLabel) {
result.displayItemLabel = resolveMessage('itemLabel', nodeName)
}
return result
}

View File

@@ -0,0 +1,397 @@
import {
ACCESS_REQUIRED_CATALOG_ID,
CONTENT_BLOCKED_CATALOG_ID,
GENERATION_STALLED_CATALOG_ID,
IMAGE_NOT_LOADED_CATALOG_ID,
INSUFFICIENT_CREDITS_CATALOG_ID,
INVALID_CLIP_INPUT_CATALOG_ID,
INVALID_PROMPT_CATALOG_ID,
INVALID_WORKFLOW_REQUEST_CATALOG_ID,
MODEL_ACCESS_ERROR_CATALOG_ID,
MODEL_DOWNLOAD_FAILED_CATALOG_ID,
OUT_OF_MEMORY_CATALOG_ID,
PREPROCESSING_FAILED_CATALOG_ID,
PREPROCESSING_TIMEOUT_CATALOG_ID,
RATE_LIMITED_CATALOG_ID,
REQUEST_FAILED_CATALOG_ID,
RUN_ENDED_UNEXPECTEDLY_CATALOG_ID,
RUN_START_FAILED_CATALOG_ID,
SERVER_BUSY_CATALOG_ID,
SERVER_CRASHED_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
TIMEOUT_CATALOG_ID,
UNEXPECTED_SERVICE_ERROR_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
} from './catalogIds'
import type { CatalogParams } from './catalogI18n'
// Runtime errors can share generic exception labels, so targeted cataloging
// relies on narrow stable messages. Keep these matches exact or prefix-based.
const INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your account to use this node.'
])
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your workspace to continue.'
])
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
'Workspace has no active subscription. Please subscribe to a plan to continue.',
'User has no active subscription. Please subscribe to a plan to continue.'
])
const SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX =
'the following private models require a subscription upgrade:'
const TIMEOUT_MESSAGES = new Set(['Job execution time exceeded maximum limit'])
const GENERATION_STALLED_MESSAGES = new Set([
'Job went too long without making any progress',
'Job has stagnated'
])
const SERVER_CRASHED_MESSAGES = new Set([
'RIP to the server your workflow was running on.',
'Inference service restarted, terminating job',
'Job stuck in erroring state, forcing terminal transition',
'Job was previously marked as lost and has now been acknowledged by inference service'
])
const SERVER_BUSY_MESSAGES = new Set([
'Failed to enqueue job for processing',
'Executor is busy with another job',
'Servers are busy. Please try again later.'
])
const INVALID_WORKFLOW_REQUEST_MESSAGES = new Set([
'The workflow request is invalid.',
'Invalid job: missing workflow',
"Invalid workflow: missing 'prompt' field",
"Invalid workflow: 'prompt' field must be an object"
])
const ACCESS_REQUIRED_MESSAGE =
'This run requires access that is not available for the current account.'
const MODEL_ACCESS_ERROR_MESSAGE =
'One or more required models could not be accessed.'
const UNEXPECTED_SERVICE_ERROR_MESSAGE = 'Unexpected service error.'
const REQUEST_FAILED_MESSAGE =
'The request failed before the run could complete.'
const RUN_START_FAILED_MESSAGE = 'The run could not be started.'
const RUN_ENDED_UNEXPECTEDLY_MESSAGE = 'The run ended unexpectedly.'
const SIGN_IN_REQUIRED_MESSAGE =
'Unauthorized: Please login first to use this node.'
const RATE_LIMITED_PREFIX = 'Rate Limit Exceeded:'
const CORE_OOM_TIP = 'This error means you ran out of memory on your GPU.'
const CORE_OOM_ALLOCATION_PREFIX = 'Allocation on device'
const CLOUD_OOM_PREFIX =
'Workflow execution failed due to insufficient memory (OOM).'
const ERRNO_DIRECTORY_MESSAGE = '[Errno 21] Is a directory:'
const INVALID_CLIP_INPUT_PREFIX = 'ERROR: clip input is invalid: None'
const PROMPT_TOO_SHORT_MESSAGE =
"Field 'prompt' cannot be shorter than 1 characters; was 0 characters long."
const PROMPT_EMPTY_MESSAGE = "Field 'prompt' cannot be empty."
const PREPROCESSING_FAILED_MESSAGE = 'Preprocessing failed'
const PREPROCESSING_TIMEOUT_MESSAGES = new Set([
'Preprocessing timed out',
'Preprocessing timed out.'
])
const MODEL_DOWNLOAD_PANIC_PREFIX = 'internal error during model download:'
const GENERATED_VIDEO_REJECTED_MESSAGE =
'Generated video rejected by content moderation.'
const GENERATED_CONTENT_REJECTED_MESSAGE =
'Generated content was rejected by a safety check.'
const SAFETY_CHECK_MESSAGE = 'Prompt or Initial Image failed the safety checks.'
const CONTENT_POLICY_VIOLATION_MESSAGE =
'The generated image was flagged for content policy violation.'
const CONTENT_MODERATION_FLAGGED_PREFIX =
'Your request was flagged by our content moderation system'
const GOOGLE_RAI_FILTERED_PREFIX =
"Content filtered by Google's Responsible AI practices"
const GOOGLE_RAI_BLOCKED_PREFIX =
"Content blocked by Google's Responsible AI filters"
const START_FAILED_PREFIXES = [
'Failed to start WebSocket client:',
'Failed to get ComfyUI generation ID:'
]
const REQUEST_FAILED_PREFIXES = ['Failed to send prompt request:']
const SERVER_CRASHED_PREFIXES = [
'Workflow execution was interrupted due to ComfyUI process restart.',
'Job execution interrupted: server shutdown.',
'Failed to clear queue and restart failed:',
'WebSocket failed to reconnect after restart:'
]
const PREPROCESSING_FAILED_PREFIXES = [
'Preprocessing failed:',
'Failed to complete preparation:'
]
interface RuntimeErrorInfo {
exceptionType: string
exceptionMessage: string
}
interface RuntimeCatalogMatch {
catalogId: string
params?: CatalogParams
detailsFallback?: string
}
interface RuntimeMatchRule {
matches: (info: RuntimeErrorInfo, message: string) => boolean
resolve: (info: RuntimeErrorInfo, message: string) => RuntimeCatalogMatch
}
function catalogMatch(
catalogId: string,
options: Omit<RuntimeCatalogMatch, 'catalogId'> = {}
): RuntimeCatalogMatch {
return { catalogId, ...options }
}
function catalogMatchWithMessageFallback(
catalogId: string,
message: string
): RuntimeCatalogMatch {
return catalogMatch(catalogId, { detailsFallback: message })
}
function isOutOfMemoryError(info: RuntimeErrorInfo): boolean {
const message = info.exceptionMessage
return (
info.exceptionType === 'OOMError' ||
message.includes(CORE_OOM_TIP) ||
message.includes(CORE_OOM_ALLOCATION_PREFIX) ||
message.includes(CLOUD_OOM_PREFIX) ||
message.includes('CUDA out of memory') ||
message.includes('GPU out of memory')
)
}
function isImageNotLoadedError(
info: RuntimeErrorInfo,
message: string
): boolean {
return (
info.exceptionType === 'ImageDownloadError' ||
(info.exceptionType === 'IsADirectoryError' &&
message.includes(ERRNO_DIRECTORY_MESSAGE))
)
}
function getSubscriptionUpgradeDetails(message: string): string {
return message.slice(SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX.length).trim()
}
function isContentBlockedError(message: string): boolean {
return (
message.includes(GENERATED_VIDEO_REJECTED_MESSAGE) ||
message.includes(GENERATED_CONTENT_REJECTED_MESSAGE) ||
message.includes(SAFETY_CHECK_MESSAGE) ||
message.includes(CONTENT_POLICY_VIOLATION_MESSAGE) ||
message.startsWith(CONTENT_MODERATION_FLAGGED_PREFIX) ||
message.startsWith(GOOGLE_RAI_FILTERED_PREFIX) ||
message.startsWith(GOOGLE_RAI_BLOCKED_PREFIX)
)
}
function startsWithAny(message: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => message.startsWith(prefix))
}
function hasEmbeddedApiErrorPayload(message: string): boolean {
// Embedded validation responses are parsed by a more specific path, so do not
// catalog them as a generic request failure here.
return /request returned error status \d{3}:\s*\{/.test(message)
}
function isSubscriptionUpgradeMessage(message: string): boolean {
return (
message.toLowerCase().startsWith(SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX) &&
getSubscriptionUpgradeDetails(message).length > 0
)
}
// Order matters: the first matching rule wins. Keep narrow user-actionable
// signatures before broader fallbacks.
const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
{
matches: isImageNotLoadedError,
resolve: (_info, message) =>
catalogMatchWithMessageFallback(IMAGE_NOT_LOADED_CATALOG_ID, message)
},
{
matches: isOutOfMemoryError,
resolve: (_info, message) =>
catalogMatchWithMessageFallback(OUT_OF_MEMORY_CATALOG_ID, message)
},
{
matches: (_info, message) => message.startsWith(INVALID_CLIP_INPUT_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(INVALID_CLIP_INPUT_CATALOG_ID, message)
},
{
matches: (_info, message) =>
message.includes(PROMPT_TOO_SHORT_MESSAGE) ||
message.includes(PROMPT_EMPTY_MESSAGE),
resolve: () => catalogMatch(INVALID_PROMPT_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'ValidationError' &&
INVALID_WORKFLOW_REQUEST_MESSAGES.has(message),
resolve: () => catalogMatch(INVALID_WORKFLOW_REQUEST_CATALOG_ID)
},
{
matches: (_info, message) =>
WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'InsufficientFundsError' ||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'InactiveSubscriptionError' ||
SUBSCRIPTION_REQUIRED_MESSAGES.has(message),
resolve: () => catalogMatch(SUBSCRIPTION_REQUIRED_CATALOG_ID)
},
{
matches: (_info, message) => isSubscriptionUpgradeMessage(message),
resolve: (_info, message) => {
const modelNames = getSubscriptionUpgradeDetails(message)
return catalogMatch(SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, {
params: { modelNames },
detailsFallback: message
})
}
},
{
matches: (info, message) =>
info.exceptionType === 'AccessRequired' ||
message === ACCESS_REQUIRED_MESSAGE,
resolve: () => catalogMatch(ACCESS_REQUIRED_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'ModelAccessError' ||
message === MODEL_ACCESS_ERROR_MESSAGE,
resolve: () => catalogMatch(MODEL_ACCESS_ERROR_CATALOG_ID)
},
{
matches: (_info, message) => message.includes(SIGN_IN_REQUIRED_MESSAGE),
resolve: () => catalogMatch(SIGN_IN_REQUIRED_CATALOG_ID)
},
{
matches: (_info, message) => message.startsWith(RATE_LIMITED_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(RATE_LIMITED_CATALOG_ID, message)
},
{
matches: (info, message) =>
info.exceptionType === 'PreprocessingTimeout' ||
PREPROCESSING_TIMEOUT_MESSAGES.has(message),
resolve: () => catalogMatch(PREPROCESSING_TIMEOUT_CATALOG_ID)
},
{
matches: (_info, message) =>
message === PREPROCESSING_FAILED_MESSAGE ||
startsWithAny(message, PREPROCESSING_FAILED_PREFIXES),
resolve: (_info, message) =>
message === PREPROCESSING_FAILED_MESSAGE
? catalogMatch(PREPROCESSING_FAILED_CATALOG_ID)
: catalogMatchWithMessageFallback(
PREPROCESSING_FAILED_CATALOG_ID,
message
)
},
{
matches: (info, message) =>
info.exceptionType === 'PanicError' &&
message.startsWith(MODEL_DOWNLOAD_PANIC_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(MODEL_DOWNLOAD_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) => isContentBlockedError(message),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(CONTENT_BLOCKED_CATALOG_ID, message)
},
{
matches: (_info, message) => startsWithAny(message, START_FAILED_PREFIXES),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(RUN_START_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) =>
startsWithAny(message, REQUEST_FAILED_PREFIXES) &&
!hasEmbeddedApiErrorPayload(message),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(REQUEST_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) => message === RUN_START_FAILED_MESSAGE,
resolve: () => catalogMatch(RUN_START_FAILED_CATALOG_ID)
},
{
matches: (_info, message) => message === RUN_ENDED_UNEXPECTEDLY_MESSAGE,
resolve: () => catalogMatch(RUN_ENDED_UNEXPECTEDLY_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'PanicError' &&
message.startsWith('panic during job execution:'),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(
RUN_ENDED_UNEXPECTEDLY_CATALOG_ID,
message
)
},
{
matches: (_info, message) => TIMEOUT_MESSAGES.has(message),
resolve: () => catalogMatch(TIMEOUT_CATALOG_ID)
},
{
matches: (_info, message) => GENERATION_STALLED_MESSAGES.has(message),
resolve: () => catalogMatch(GENERATION_STALLED_CATALOG_ID)
},
{
matches: (_info, message) => SERVER_CRASHED_MESSAGES.has(message),
resolve: () => catalogMatch(SERVER_CRASHED_CATALOG_ID)
},
{
matches: (_info, message) =>
startsWithAny(message, SERVER_CRASHED_PREFIXES),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(SERVER_CRASHED_CATALOG_ID, message)
},
{
matches: (_info, message) => SERVER_BUSY_MESSAGES.has(message),
resolve: () => catalogMatch(SERVER_BUSY_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'UnexpectedServiceError' ||
message === UNEXPECTED_SERVICE_ERROR_MESSAGE,
resolve: () => catalogMatch(UNEXPECTED_SERVICE_ERROR_CATALOG_ID)
},
{
matches: (info, message) =>
message === REQUEST_FAILED_MESSAGE ||
(info.exceptionType === 'RequestError' &&
!hasEmbeddedApiErrorPayload(message)),
resolve: (_info, message) =>
message === REQUEST_FAILED_MESSAGE
? catalogMatch(REQUEST_FAILED_CATALOG_ID)
: catalogMatchWithMessageFallback(REQUEST_FAILED_CATALOG_ID, message)
}
]
export function resolveRuntimeCatalogMatch(
info: RuntimeErrorInfo
): RuntimeCatalogMatch | undefined {
const message = info.exceptionMessage.trim()
for (const rule of RUNTIME_MATCH_RULES) {
if (rule.matches(info, message)) return rule.resolve(info, message)
}
return undefined
}

View File

@@ -1,4 +1,8 @@
import type { NodeError, PromptError } from '@/schemas/apiSchema'
import type {
ExecutionErrorWsMessage,
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type {
MissingMediaGroup,
MediaType
@@ -40,6 +44,11 @@ export type RunErrorMessageSource =
error: PromptError
isCloud: boolean
}
| {
kind: 'execution'
error: ExecutionErrorWsMessage
nodeDisplayName: string
}
export type MissingErrorMessageSource =
| {

View File

@@ -0,0 +1,347 @@
import type { NodeValidationError, ResolvedErrorMessage } from './types'
import {
IMAGE_NOT_LOADED_CATALOG_ID,
MISSING_CONNECTION_CATALOG_ID
} from './catalogIds'
import {
normalizeNodeName,
translateCatalogMessage,
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
// Resolves node validation errors. Most validation types map 1:1 to their
// catalog/locale keys; FE-specific recategorization uses a separate catalogId,
// such as required_input_missing -> missing_connection.
interface ValidationCatalogRule {
catalogId: string
itemLabel: 'node' | 'nodeInput'
copyKeys?: CopyKeys
}
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'
}
}
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
[REQUIRED_INPUT_MISSING_TYPE]: {
catalogId: MISSING_CONNECTION_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 type-keyed
// dispatch cannot distinguish it. The override also keeps it on default copy
// keys instead of custom_validation_failed's raw-details variant.
const IMAGE_NOT_LOADED_VALIDATION_RULE = {
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
itemLabel: 'node',
copyKeys: DEFAULT_COPY_KEYS
} satisfies ValidationCatalogRule
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
inputName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
)
}
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 {
// Dependency cycle paths may be reported 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)
}
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' ||
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)
}
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
)
}
}
export function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedErrorMessage {
if (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
context,
'image_not_loaded',
IMAGE_NOT_LOADED_VALIDATION_RULE
)
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) return {}
return resolveValidationCatalogCopy(error, context, error.type, rule)
}

View File

@@ -30,7 +30,7 @@ type FirebaseRuntimeConfig = {
* be tweaked without a frontend release. Field types map 1:1 to a component
* in our internal UI library — see `DynamicSurveyField.vue`.
*/
export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
/**
* A translatable string. Either:

View File

@@ -0,0 +1,98 @@
/**
* Settings dialog migration regression net: `useSettingsDialog().show()` must
* open the Reka-renderer path with sizing that matches the previous
* `BaseModalLayout size="sm"` (960px × 80vh). Catches accidental reverts of
* the Phase 3 renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const teamWorkspacesFlag = vi.hoisted(() => ({ value: false }))
const isCloudRef = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog, closeDialog: vi.fn() })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return teamWorkspacesFlag.value
}
}
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return isCloudRef.value
}
}))
vi.mock('@/i18n', () => ({ t: (k: string) => k }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEvent: vi.fn() })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})
}))
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
describe('useSettingsDialog', () => {
beforeEach(() => {
showDialog.mockReset()
teamWorkspacesFlag.value = false
isCloudRef.value = false
})
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-settings')
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('full')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
})
it('show() uses non-modal Reka so nested PrimeVue dialogs keep focus and pointer events', () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.modal).toBe(false)
})
it('show() omits overlayClass when not in workspace mode', () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.overlayClass).toBeUndefined()
})
it("show() sets overlayClass 'p-8' when isCloud && teamWorkspacesEnabled", () => {
isCloudRef.value = true
teamWorkspacesFlag.value = true
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.overlayClass).toBe('p-8')
})
it('show(panel) forwards defaultPanel to the dialog props', () => {
useSettingsDialog().show('about')
const [args] = showDialog.mock.calls[0]
expect(args.props.defaultPanel).toBe('about')
})
it('showAbout() opens the about panel', () => {
useSettingsDialog().showAbout()
const [args] = showDialog.mock.calls[0]
expect(args.props.defaultPanel).toBe('about')
})
})

View File

@@ -1,3 +1,5 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -6,15 +8,20 @@ import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
const SETTINGS_CONTENT_CLASS =
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
export function useSettingsDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(panel?: SettingPanelType, settingId?: string) {
const isWorkspaceMode = isCloud && flags.teamWorkspacesEnabled
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: SettingDialog,
@@ -22,6 +29,18 @@ export function useSettingsDialog() {
onClose: hide,
...(panel ? { defaultPanel: panel } : {}),
...(settingId ? { scrollToSettingId: settingId } : {})
},
dialogComponentProps: {
renderer: 'reka',
// Settings hosts nested PrimeVue dialogs (Edit Keybinding, Overwrite
// confirm, etc.) that teleport to body. Reka's modal mode traps focus
// inside the Settings content and disables body pointer-events, which
// breaks those nested dialogs' autofocus and click handling. Non-modal
// keeps the visual overlay without those traps.
modal: false,
size: 'full',
contentClass: SETTINGS_CONTENT_CLASS,
overlayClass: isWorkspaceMode ? 'p-8' : undefined
}
})
}

View File

@@ -4,7 +4,6 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { app } from '@/scripts/app'
// Mock stores
vi.mock('@/renderer/core/canvas/canvasStore', () => {
@@ -36,19 +35,15 @@ function createMockLGraphCanvas(read_only = true): LGraphCanvas {
return mockCanvas as LGraphCanvas
}
function createMockPointerEvent({
type = 'pointermove',
button = 0,
buttons = 1
}: {
type?: string
button?: PointerEvent['button']
buttons?: PointerEvent['buttons']
} = {}): PointerEvent {
const event = new PointerEvent(type, { button, buttons })
vi.spyOn(event, 'preventDefault')
vi.spyOn(event, 'stopPropagation')
return event
function createMockPointerEvent(
buttons: PointerEvent['buttons'] = 1
): PointerEvent {
const mockEvent: Partial<PointerEvent> = {
buttons,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
return mockEvent as PointerEvent
}
function createMockWheelEvent(
@@ -81,47 +76,24 @@ describe('useCanvasInteractions', () => {
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent({ buttons: 1 })
const mockEvent = createMockPointerEvent(1) // Left Mouse Button
handlePointer(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should forward middle pointerdown events to canvas', () => {
it('should forward middle mouse button events to canvas', () => {
const { getCanvas } = useCanvasStore()
const mockCanvas = createMockLGraphCanvas(false)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent({
type: 'pointerdown',
button: 1,
buttons: 4
})
const mockEvent = createMockPointerEvent(4) // Middle mouse button
handlePointer(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
expect(app.canvas.canvas.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'pointerdown' })
)
})
it('should forward chorded middle-button drags to canvas', () => {
const { getCanvas } = useCanvasStore()
const mockCanvas = createMockLGraphCanvas(false)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent({ buttons: 5 })
handlePointer(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
expect(app.canvas.canvas.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'pointermove' })
)
})
it('should not prevent default when canvas is not in read_only mode and not middle button', () => {
@@ -130,7 +102,7 @@ describe('useCanvasInteractions', () => {
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent({ buttons: 1 })
const mockEvent = createMockPointerEvent(1)
handlePointer(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
@@ -142,7 +114,7 @@ describe('useCanvasInteractions', () => {
vi.mocked(getCanvas).mockReturnValue(null!)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent({ buttons: 1 })
const mockEvent = createMockPointerEvent(1)
handlePointer(mockEvent)
expect(getCanvas).toHaveBeenCalled()

View File

@@ -1,6 +1,6 @@
import { computed } from 'vue'
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -77,7 +77,7 @@ export function useCanvasInteractions() {
* be forwarded to canvas (e.g., space+drag for panning)
*/
const handlePointer = (event: PointerEvent) => {
if (isMiddleForPointerEvent(event)) {
if (isMiddlePointerInput(event)) {
forwardEventToCanvas(event)
return
}
@@ -86,10 +86,15 @@ export function useCanvasInteractions() {
const canvas = getCanvas()
if (!canvas) return
if (canvas.read_only && event.buttons === 1) {
// Check conditions for forwarding events to canvas
const isSpacePanningDrag = canvas.read_only && event.buttons === 1 // Space key pressed + left mouse drag
const isMiddleMousePanning = event.buttons === 4 // Middle mouse button for panning
if (isSpacePanningDrag || isMiddleMousePanning) {
event.preventDefault()
event.stopPropagation()
forwardEventToCanvas(event)
return
}
}

View File

@@ -2,8 +2,6 @@ import { useDebounceFn, useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
interface TransformSettlingOptions {
/**
* Delay in ms before transform is considered "settled" after last interaction
@@ -87,7 +85,7 @@ function usePointerDrag(
'pointerdown',
(e: PointerEvent) => {
// Only primary (0) and middle (1) buttons trigger canvas pan.
if (e.button === 0 || isMiddlePointerInput(e)) pointerCount.value++
if (e.button === 0 || e.button === 1) pointerCount.value++
},
eventOptions
)

View File

@@ -7,30 +7,32 @@
<!-- Grid View -->
<div
v-if="viewMode === 'grid'"
ref="gridEl"
data-testid="image-grid"
class="group/panel relative grid w-full gap-1 overflow-hidden rounded-sm p-1"
class="relative grid w-full flex-1 gap-1 rounded-sm p-1 contain-size"
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
>
<button
<Button
v-for="(url, index) in imageUrls"
:key="index"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
size="unset"
class="ring-ring overflow-hidden rounded-none p-0 hover:ring-1 focus-visible:ring-2"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@pointerdown="trackPointerStart"
@click="handleGridThumbnailClick($event, index)"
@click="openImageInGallery(index)"
>
<img
:src="url"
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
draggable="false"
class="pointer-events-none size-full object-contain"
@load="updateAspectRatio($event, index)"
/>
</button>
</Button>
</div>
<!-- Gallery View (Image Wrapper) -->
@@ -167,11 +169,12 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useElementSize, useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -202,12 +205,17 @@ function defaultViewMode(urls: readonly string[]): ViewMode {
return urls.length > 1 ? 'grid' : 'gallery'
}
const { width: gridWidth, height: gridHeight } = useElementSize(
useTemplateRef('gridEl')
)
const currentIndex = ref(0)
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
const galleryPanelEl = ref<HTMLDivElement>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const imageAspectRatio = ref(1)
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
@@ -227,10 +235,8 @@ const imageAltText = computed(() =>
})
)
const gridCols = computed(() => {
const count = imageUrls.length
if (count <= 4) return 2
if (count <= 9) return 3
return 4
const bias = gridWidth.value / gridHeight.value / imageAspectRatio.value
return Math.max(Math.round(Math.sqrt(imageUrls.length * bias)), 1)
})
watch(
@@ -274,6 +280,14 @@ function handleImageLoad(event: Event) {
}
}
function updateAspectRatio(event: Event, index: number) {
if (!(event.target instanceof HTMLImageElement) || index !== 0) return
const { naturalWidth, naturalHeight } = event.target
if (naturalWidth && naturalHeight) {
imageAspectRatio.value = naturalWidth / naturalHeight
}
}
function handleImageError() {
stopDelayedLoader()
showLoader.value = false
@@ -310,20 +324,6 @@ function setCurrentIndex(index: number) {
}
}
const CLICK_THRESHOLD = 3
let pointerStartPos = { x: 0, y: 0 }
function trackPointerStart(event: PointerEvent) {
pointerStartPos = { x: event.clientX, y: event.clientY }
}
function handleGridThumbnailClick(event: MouseEvent, index: number) {
const dx = event.clientX - pointerStartPos.x
const dy = event.clientY - pointerStartPos.y
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) return
openImageInGallery(index)
}
async function openImageInGallery(index: number) {
setCurrentIndex(index)
viewMode.value = 'gallery'

View File

@@ -212,6 +212,7 @@
v-for="handle in RESIZE_HANDLES"
:key="handle.corner"
role="button"
:data-corner="handle.corner"
:aria-label="t(handle.i18nKey)"
:class="
cn(

View File

@@ -1,7 +1,7 @@
import { onScopeDispose, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { useClickDragGuard } from '@/composables/useClickDragGuard'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
@@ -22,7 +22,7 @@ export function useNodePointerInteractions(
const { nodeManager } = useVueNodeLifecycle()
const forwardMiddlePointerIfNeeded = (event: PointerEvent) => {
if (!isMiddleForPointerEvent(event)) return false
if (!isMiddlePointerInput(event)) return false
forwardEventToCanvas(event)
return true
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { RESIZE_HANDLES, hasNorthEdge, hasWestEdge } from './resizeHandleConfig'
describe('hasWestEdge', () => {
it.for<[CompassCorners, boolean]>([
['NW', true],
['SW', true],
['NE', false],
['SE', false]
])('corner %s -> %s', ([corner, expected]) => {
expect(hasWestEdge(corner)).toBe(expected)
})
})
describe('hasNorthEdge', () => {
it.for<[CompassCorners, boolean]>([
['NW', true],
['NE', true],
['SW', false],
['SE', false]
])('corner %s -> %s', ([corner, expected]) => {
expect(hasNorthEdge(corner)).toBe(expected)
})
})
describe('RESIZE_HANDLES', () => {
it('defines exactly one entry per CompassCorners member', () => {
const expected = new Set<CompassCorners>(['NE', 'NW', 'SE', 'SW'])
const actual = new Set(RESIZE_HANDLES.map((handle) => handle.corner))
expect(actual).toEqual(expected)
expect(RESIZE_HANDLES).toHaveLength(expected.size)
})
})

View File

@@ -43,3 +43,11 @@ export const RESIZE_HANDLES: ResizeHandle[] = [
svgTransform: 'scale(-1, -1)'
}
] as const
/** True for corners on the left edge of a node (SW, NW) — these move the x-origin when dragged. */
export const hasWestEdge = (corner: CompassCorners): boolean =>
corner === 'SW' || corner === 'NW'
/** True for corners on the top edge of a node (NE, NW) — these move the y-origin when dragged. */
export const hasNorthEdge = (corner: CompassCorners): boolean =>
corner === 'NE' || corner === 'NW'

View File

@@ -8,6 +8,10 @@ import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTran
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import {
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
export interface ResizeCallbackPayload {
size: Size
@@ -135,20 +139,23 @@ export function useNodeResize(
break
}
const isWestCorner = hasWestEdge(activeCorner)
const isNorthCorner = hasNorthEdge(activeCorner)
// Apply snap-to-grid
if (shouldSnap(moveEvent)) {
// Snap position first for N/W corners, then compensate size
if (activeCorner.includes('N') || activeCorner.includes('W')) {
if (isNorthCorner || isWestCorner) {
const originalX = newX
const originalY = newY
const snapped = applySnapToPosition({ x: newX, y: newY })
newX = snapped.x
newY = snapped.y
if (activeCorner.includes('N')) {
if (isNorthCorner) {
newHeight += originalY - newY
}
if (activeCorner.includes('W')) {
if (isWestCorner) {
newWidth += originalX - newX
}
}
@@ -166,7 +173,7 @@ export function useNodeResize(
parseFloat(nodeElement.style.getPropertyValue('min-width') || '0') ||
MIN_NODE_WIDTH
if (newWidth < minWidth) {
if (activeCorner.includes('W')) {
if (isWestCorner) {
newX =
resizeStartPosition.value.x + resizeStartSize.value.width - minWidth
}
@@ -179,7 +186,7 @@ export function useNodeResize(
// a responsive breakpoint.
const minContentHeight = measureMinContentHeight(newWidth)
if (newHeight < minContentHeight) {
if (activeCorner.includes('N')) {
if (isNorthCorner) {
newY =
resizeStartPosition.value.y +
resizeStartSize.value.height -

View File

@@ -10,7 +10,6 @@ import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
@@ -106,7 +105,29 @@ function addMarkdownWidget(
signal
})
forwardMiddleButtonToCanvas(inputEl, signal)
inputEl.addEventListener(
'pointerdown',
(event) => {
if (event.button === 1) app.canvas.processMouseDown(event)
},
{ signal }
)
inputEl.addEventListener(
'pointermove',
(event) => {
if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event)
},
{ signal }
)
inputEl.addEventListener(
'pointerup',
(event) => {
if (event.button === 1) app.canvas.processMouseUp(event)
},
{ signal }
)
widget.onRemove = useChainCallback(widget.onRemove, () => {
controller.abort()

View File

@@ -3,7 +3,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
import { useSettingStore } from '@/platform/settings/settingStore'
import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas'
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
@@ -67,7 +66,30 @@ function addMultilineWidget(
{ signal }
)
forwardMiddleButtonToCanvas(inputEl, signal)
// Allow middle mouse button panning
inputEl.addEventListener(
'pointerdown',
(event: PointerEvent) => {
if (event.button === 1) app.canvas.processMouseDown(event)
},
{ signal }
)
inputEl.addEventListener(
'pointermove',
(event: PointerEvent) => {
if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event)
},
{ signal }
)
inputEl.addEventListener(
'pointerup',
(event: PointerEvent) => {
if (event.button === 1) app.canvas.processMouseUp(event)
},
{ signal }
)
inputEl.addEventListener(
'wheel',

View File

@@ -1,57 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas'
const { processMouseDown, processMouseMove, processMouseUp } = vi.hoisted(
() => ({
processMouseDown: vi.fn(),
processMouseMove: vi.fn(),
processMouseUp: vi.fn()
})
)
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
processMouseDown,
processMouseMove,
processMouseUp
}
}
}))
describe('forwardMiddleButtonToCanvas', () => {
let inputEl: HTMLElement
let controller: AbortController
beforeEach(() => {
vi.clearAllMocks()
inputEl = document.createElement('div')
controller = new AbortController()
forwardMiddleButtonToCanvas(inputEl, controller.signal)
})
it('uses event-specific middle-button semantics', () => {
inputEl.dispatchEvent(
new PointerEvent('pointerdown', { button: 0, buttons: 5 })
)
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 5 }))
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 }))
expect(processMouseDown).not.toHaveBeenCalled()
expect(processMouseMove).toHaveBeenCalledTimes(1)
expect(processMouseUp).toHaveBeenCalledTimes(1)
})
it('detaches listeners through the provided signal', () => {
controller.abort()
inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 }))
inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 }))
inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 }))
expect(processMouseDown).not.toHaveBeenCalled()
expect(processMouseMove).not.toHaveBeenCalled()
expect(processMouseUp).not.toHaveBeenCalled()
})
})

View File

@@ -1,33 +0,0 @@
import { isMiddleForPointerEvent } from '@/base/pointerUtils'
import { app } from '@/scripts/app'
export function forwardMiddleButtonToCanvas(
inputEl: HTMLElement,
signal?: AbortSignal
): void {
const options = signal ? { signal } : undefined
inputEl.addEventListener(
'pointerdown',
(event: PointerEvent) => {
if (isMiddleForPointerEvent(event)) app.canvas.processMouseDown(event)
},
options
)
inputEl.addEventListener(
'pointermove',
(event: PointerEvent) => {
if (isMiddleForPointerEvent(event)) app.canvas.processMouseMove(event)
},
options
)
inputEl.addEventListener(
'pointerup',
(event: PointerEvent) => {
if (isMiddleForPointerEvent(event)) app.canvas.processMouseUp(event)
},
options
)
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
import { isUuidShapedSubgraphId, zSubgraphId } from './subgraphIdSchema'
const CANONICAL_UUID = '550e8400-e29b-41d4-a716-446655440000'
const INVALID_STRING_CASES: Array<[label: string, value: string]> = [
['empty string', ''],
['arbitrary path', '/some/path'],
['plain word', 'subgraph'],
['hash leftover', '#abc'],
['hex but not UUID-shaped', 'abcdef0123456789'],
['UUID with leading hash', `#${CANONICAL_UUID}`],
['UUID with whitespace', ` ${CANONICAL_UUID} `]
]
const NON_STRING_CASES: Array<[label: string, value: unknown]> = [
['number', 123],
['undefined', undefined],
['null', null],
['object', { id: 'abc' }]
]
describe('subgraphIdSchema', () => {
describe('zSubgraphId', () => {
it('accepts a freshly generated UUID v4', () => {
expect(zSubgraphId.safeParse(createUuidv4()).success).toBe(true)
})
it('accepts a canonical UUID string', () => {
expect(zSubgraphId.safeParse(CANONICAL_UUID).success).toBe(true)
})
it.for(INVALID_STRING_CASES)('rejects %s', ([_label, value]) => {
expect(zSubgraphId.safeParse(value).success).toBe(false)
})
it.for(NON_STRING_CASES)('rejects non-string %s', ([_label, value]) => {
expect(zSubgraphId.safeParse(value).success).toBe(false)
})
})
describe('isUuidShapedSubgraphId', () => {
it('returns true for a valid UUID', () => {
expect(isUuidShapedSubgraphId(createUuidv4())).toBe(true)
})
it('returns false for an invalid value', () => {
expect(isUuidShapedSubgraphId('not-a-uuid')).toBe(false)
expect(isUuidShapedSubgraphId(undefined)).toBe(false)
expect(isUuidShapedSubgraphId(42)).toBe(false)
})
})
})

View File

@@ -0,0 +1,10 @@
import { z } from 'zod'
/** Hash values from the URL bar are untrusted; validate before lookup. */
export const zSubgraphId = z.string().uuid()
type SubgraphId = z.infer<typeof zSubgraphId>
export function isUuidShapedSubgraphId(value: unknown): value is SubgraphId {
return zSubgraphId.safeParse(value).success
}

View File

@@ -812,8 +812,8 @@ export class ComfyApi extends EventTarget {
locale && locale !== 'en' ? `index.${locale}.json` : 'index.json'
try {
const res = await axios.get(this.fileURL(`/templates/${fileName}`))
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : []
const contentType = String(res.headers['content-type'] ?? '')
return contentType.includes('application/json') ? res.data : []
} catch (error) {
// Fallback to default English version if localized version doesn't exist
if (locale && locale !== 'en') {
@@ -1411,8 +1411,8 @@ export class ComfyApi extends EventTarget {
}
}
)
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : null
const contentType = String(res.headers['content-type'] ?? '')
return contentType.includes('application/json') ? res.data : null
} catch (error) {
console.error('Error loading fuse options:', error)
return null

View File

@@ -4,9 +4,8 @@ import { merge } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { markRaw, ref } from 'vue'
import type { Component, HTMLAttributes } from 'vue'
import type { Component, HTMLAttributes, Ref } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import type { DialogContentSize } from '@/components/ui/dialog/dialog.variants'
import type { ComponentAttrs } from 'vue-component-type-helpers'
@@ -48,25 +47,26 @@ interface CustomDialogComponentProps {
* PrimeVue path — use `pt` for that renderer.
*/
contentClass?: HTMLAttributes['class']
/**
* Class applied to the Reka-UI `DialogOverlay` element. Ignored on the
* PrimeVue path — use `pt.mask` for that renderer.
*/
overlayClass?: HTMLAttributes['class']
}
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
export type DialogComponentProps = Record<string, unknown> &
CustomDialogComponentProps
export interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
export interface DialogInstance {
key: string
visible: boolean
title?: string
headerComponent?: H
headerProps?: ComponentAttrs<H>
component: B
contentProps: ComponentAttrs<B>
footerComponent?: F
footerProps?: ComponentAttrs<F>
headerComponent?: Component
headerProps?: Record<string, unknown>
component: Component
contentProps: Record<string, unknown>
footerComponent?: Component
footerProps?: Record<string, unknown>
dialogComponentProps: DialogComponentProps
priority: number
}
@@ -100,7 +100,7 @@ interface UpdateDialogOptions {
}
export const useDialogStore = defineStore('dialog', () => {
const dialogStack = ref<DialogInstance[]>([])
const dialogStack: Ref<DialogInstance[]> = ref([])
/**
* The key of the currently active (top-most) dialog.
@@ -118,7 +118,6 @@ export const useDialogStore = defineStore('dialog', () => {
const insertIndex = dialogStack.value.findIndex(
(d) => d.priority <= dialog.priority
)
dialogStack.value.splice(
insertIndex === -1 ? dialogStack.value.length : insertIndex,
0,
@@ -145,8 +144,8 @@ export const useDialogStore = defineStore('dialog', () => {
if (!targetDialog) return
targetDialog.dialogComponentProps?.onClose?.()
const index = dialogStack.value.indexOf(targetDialog)
dialogStack.value.splice(index, 1)
const index = dialogStack.value.findIndex((d) => d.key === targetDialog.key)
if (index !== -1) dialogStack.value.splice(index, 1)
activeKey.value =
dialogStack.value.length > 0

View File

@@ -87,7 +87,8 @@ const MOCK_NODE_NAMES = [
'IPAdapterModelLoader',
'LS_LoadSegformerModel',
'LoadNLFModel',
'FlashVSRNode'
'FlashVSRNode',
'LTXICLoRALoaderModelOnly'
] as const
const mockNodeDefsByName = Object.fromEntries(
@@ -307,7 +308,22 @@ describe('useModelToNodeStore', () => {
)
const loraProviders = modelToNodeStore.getAllNodeProviders('loras')
expect(loraProviders).toHaveLength(2)
expect(loraProviders).toHaveLength(3)
expect(loraProviders).toEqual(
expect.arrayContaining([
expect.objectContaining({
nodeDef: expect.objectContaining({ name: 'LoraLoader' })
}),
expect.objectContaining({
nodeDef: expect.objectContaining({ name: 'LoraLoaderModelOnly' })
}),
expect.objectContaining({
nodeDef: expect.objectContaining({
name: 'LTXICLoRALoaderModelOnly'
})
})
])
)
})
it('should return single provider for model type with one node', () => {
@@ -561,6 +577,18 @@ describe('useModelToNodeStore', () => {
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('LTXICLoRALoaderModelOnly')
).toBe('loras')
expect(
modelToNodeStore.getRegisteredNodeTypes()['LTXICLoRALoaderModelOnly']
).toBe('lora_name')
})
it('should return first category when node type exists in multiple categories', () => {
const modelToNodeStore = useModelToNodeStore()

View File

@@ -0,0 +1,307 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type * as VueRouter from 'vue-router'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
const ids = vi.hoisted(() => ({
root: '00000000-0000-4000-8000-000000000000',
validSubgraph: '11111111-1111-4111-8111-111111111111',
deletedSubgraph: '22222222-2222-4222-8222-222222222222'
}))
const workflowStoreState = vi.hoisted(() => ({
openWorkflows: [] as unknown[],
activeSubgraph: undefined as unknown
}))
const routerMocks = vi.hoisted(() => ({
push: vi.fn().mockResolvedValue(undefined),
replace: vi.fn().mockResolvedValue(undefined)
}))
const routeHashRef = ref('')
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof VueRouter>()
return {
...actual,
useRouter: () => routerMocks
}
})
vi.mock('@vueuse/router', () => ({
useRouteHash: () => routeHashRef
}))
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: null,
graph: null,
setGraph: vi.fn(),
setDirty: vi.fn(),
ds: {
scale: 1,
offset: [0, 0],
state: { scale: 1, offset: [0, 0] }
}
}
const mockRoot = {
id: ids.root,
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
}
return {
app: {
graph: mockRoot,
rootGraph: mockRoot,
canvas: mockCanvas
}
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => app.canvas,
currentGraph: null
})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ fitView: vi.fn() })
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({ requestSlotLayoutSyncForAllNodes: vi.fn() })
)
const workflowServiceMocks = vi.hoisted(() => ({
openWorkflow: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => workflowServiceMocks
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStoreState
}))
function makeSubgraph(id: string): Subgraph {
return fromPartial<Subgraph>({
id,
rootGraph: app.rootGraph,
_nodes: [],
nodes: []
})
}
async function flushHashWatcher() {
await nextTick()
await Promise.resolve()
await nextTick()
}
describe('useSubgraphNavigationStore - navigateToHash validation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.rootGraph.id = ids.root
app.rootGraph.subgraphs.clear()
app.canvas.subgraph = undefined
app.canvas.graph = app.rootGraph
workflowStoreState.openWorkflows = []
workflowStoreState.activeSubgraph = undefined
routeHashRef.value = ''
})
it('navigates to a valid, existing subgraph hash', async () => {
const subgraph = makeSubgraph(ids.validSubgraph)
app.rootGraph.subgraphs.set(subgraph.id, subgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.validSubgraph}`
await flushHashWatcher()
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
expect(routerMocks.replace).not.toHaveBeenCalled()
})
it('redirects to root when hash references a deleted subgraph', async () => {
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
})
it('redirects to root when hash is malformed (not a UUID)', async () => {
useSubgraphNavigationStore()
routeHashRef.value = '#not-a-valid-uuid'
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
expect(app.canvas.setGraph).not.toHaveBeenCalled()
})
it('does not redirect when hash equals a non-UUID root graph id (loaded workflow slug)', async () => {
const slugRootId = 'test-missing-models-in-subgraph'
app.rootGraph.id = slugRootId
app.canvas.graph = fromPartial<LGraph>({ id: slugRootId })
useSubgraphNavigationStore()
routeHashRef.value = `#${slugRootId}`
await flushHashWatcher()
expect(routerMocks.replace).not.toHaveBeenCalled()
expect(app.canvas.setGraph).not.toHaveBeenCalled()
})
it('redirects when hash is a non-UUID slug that does not match root', async () => {
useSubgraphNavigationStore()
routeHashRef.value = '#some-other-slug'
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
})
it('does not redirect or re-set graph when hash equals current root graph', async () => {
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.root}`
await flushHashWatcher()
expect(app.canvas.setGraph).not.toHaveBeenCalled()
expect(routerMocks.replace).not.toHaveBeenCalled()
})
it('does not redirect when transitioning to an empty hash on the root graph', async () => {
routeHashRef.value = `#${ids.root}`
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
useSubgraphNavigationStore()
await flushHashWatcher()
routerMocks.replace.mockClear()
vi.mocked(app.canvas.setGraph).mockClear()
routeHashRef.value = ''
await flushHashWatcher()
expect(routerMocks.replace).not.toHaveBeenCalled()
expect(app.canvas.setGraph).not.toHaveBeenCalled()
})
it('redirects when canvas still references a deleted subgraph (stale-graph guard)', async () => {
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() => {
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
})
})
it('recovers canvas to root even if router.replace rejects', async () => {
routerMocks.replace.mockRejectedValueOnce(new Error('navigation aborted'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
)
warnSpy.mockRestore()
})
it('redirects when a workflow load resolves but the subgraph is still missing', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({
path: 'phantom-workflow.json',
activeState: {
id: ids.deletedSubgraph,
definitions: { subgraphs: [] }
}
})
]
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() => {
expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled()
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('subgraph not found after workflow load')
)
})
warnSpy.mockRestore()
})
it('redirects when openWorkflow rejects during recovery', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
workflowServiceMocks.openWorkflow.mockRejectedValueOnce(
new Error('load failed')
)
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({
path: 'broken-workflow.json',
activeState: {
id: ids.deletedSubgraph,
definitions: { subgraphs: [] }
}
})
]
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() => {
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('workflow load failed')
)
})
warnSpy.mockRestore()
})
it('routeHash watcher does not re-enter navigateToHash during recovery redirect', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// Simulate the real router replace: trigger the routeHash watcher
// exactly the way vue-router does when the URL is replaced.
routerMocks.replace.mockImplementation((target) => {
const hash = typeof target === 'string' ? target : ''
routeHashRef.value = hash
return Promise.resolve(undefined)
})
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() => {
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
})
// navigateToHash for the deleted id ran once and produced exactly one
// redirect. The watcher must NOT have fired again for the rewritten
// (root) hash and produced a second redirect.
expect(routerMocks.replace).toHaveBeenCalledTimes(1)
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
warnSpy.mockRestore()
})
})

View File

@@ -2,7 +2,11 @@ import QuickLRU from '@alloc/quick-lru'
import { useRouteHash } from '@vueuse/router'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
NavigationFailureType,
isNavigationFailure,
useRouter
} from 'vue-router'
import type { DragAndScaleState } from '@/lib/litegraph/src/DragAndScale'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
@@ -10,6 +14,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { isUuidShapedSubgraphId } from '@/schemas/subgraphIdSchema'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
@@ -200,20 +205,64 @@ export const useSubgraphNavigationStore = defineStore(
{ flush: 'sync' }
)
//Allow navigation with forward/back buttons
let blockHashUpdate = false
// Counter so nested/overlapping async navigations don't release
// suppression early; gates both the canvasStore.currentGraph watcher
// (updateHash) and the routeHash watcher to prevent re-entrant
// navigateToHash calls during router.replace().
let blockNavDepth = 0
let initialLoad = true
async function withNavBlocked<T>(op: () => Promise<T>): Promise<T> {
blockNavDepth++
try {
return await op()
} finally {
blockNavDepth--
}
}
function ensureCanvasOnRoot() {
const root = app.rootGraph
const canvas = canvasStore.getCanvas()
if (!root || !canvas) return
if (canvas.graph?.id !== root.id) canvas.setGraph(root)
}
async function redirectToRoot(reason: string) {
const root = app.rootGraph
console.warn(`[subgraphNavigation] ${reason}; redirecting to root graph`)
try {
await withNavBlocked(() => router.replace('#' + root.id))
} catch (err) {
if (
!isNavigationFailure(err, NavigationFailureType.duplicated) &&
!isNavigationFailure(err, NavigationFailureType.cancelled)
) {
console.warn(
'[subgraphNavigation] router.replace rejected during recovery',
err
)
}
} finally {
ensureCanvasOnRoot()
}
}
async function navigateToHash(newHash: string) {
const root = app.rootGraph
const locatorId = newHash?.slice(1) || root.id
const canvas = canvasStore.getCanvas()
if (canvas.graph?.id === locatorId) return
const targetGraph =
(locatorId || root.id) !== root.id
const isRoot = locatorId === root.id
const targetGraph = isRoot
? root
: isUuidShapedSubgraphId(locatorId)
? root.subgraphs.get(locatorId)
: root
if (targetGraph) return canvas.setGraph(targetGraph)
: undefined
if (targetGraph) {
if (canvas.graph?.id === targetGraph.id) return
return canvas.setGraph(targetGraph)
}
//Search all open workflows
for (const workflow of workflowStore.openWorkflows) {
@@ -222,29 +271,48 @@ export const useSubgraphNavigationStore = defineStore(
const subgraphs = activeState.definitions?.subgraphs ?? []
for (const graph of [activeState, ...subgraphs]) {
if (graph.id !== locatorId) continue
//This will trigger a navigation, which can break forward history
// This will trigger a navigation, which can break forward history.
// After openWorkflow resolves, app.rootGraph has been swapped, so we
// intentionally re-read app.rootGraph below instead of using the
// `root` captured at function entry.
try {
blockHashUpdate = true
await useWorkflowService().openWorkflow(workflow)
} finally {
blockHashUpdate = false
await withNavBlocked(() =>
useWorkflowService().openWorkflow(workflow)
)
} catch (err) {
console.warn(
'[subgraphNavigation] openWorkflow rejected during recovery',
err
)
return redirectToRoot('workflow load failed')
}
const targetGraph =
const loadedGraph =
app.rootGraph.id === locatorId
? app.rootGraph
: app.rootGraph.subgraphs.get(locatorId)
if (!targetGraph) {
console.error('subgraph poofed after load?')
return
if (!loadedGraph) {
return redirectToRoot('subgraph not found after workflow load')
}
if (canvas.graph?.id === loadedGraph.id) return
return canvas.setGraph(loadedGraph)
}
}
return canvas.setGraph(targetGraph)
await redirectToRoot(`subgraph not found: ${locatorId}`)
}
async function safeRouterCall(op: () => Promise<unknown>, label: string) {
try {
await op()
} catch (err) {
if (!isNavigationFailure(err, NavigationFailureType.duplicated)) {
console.warn(`[subgraphNavigation] ${label} rejected`, err)
}
}
}
async function updateHash() {
if (blockHashUpdate) return
if (blockNavDepth > 0) return
if (initialLoad) {
initialLoad = false
if (!routeHash.value) return
@@ -255,16 +323,22 @@ export const useSubgraphNavigationStore = defineStore(
}
const newId = canvasStore.getCanvas().graph?.id ?? ''
if (!routeHash.value) await router.replace('#' + app.rootGraph.id)
if (!routeHash.value) {
await safeRouterCall(
() => router.replace('#' + app.rootGraph.id),
'router.replace'
)
}
const currentId = routeHash.value?.slice(1)
if (!newId || newId === currentId) return
await router.push('#' + newId)
await safeRouterCall(() => router.push('#' + newId), 'router.push')
}
//update navigation hash
//NOTE: Doesn't apply on workflow load
watch(() => canvasStore.currentGraph, updateHash)
watch(routeHash, () => navigateToHash(String(routeHash.value)))
watch(routeHash, () => {
if (blockNavDepth > 0) return
void navigateToHash(String(routeHash.value))
})
/** Save the current viewport for the active graph/workflow. Called by
* workflowService.beforeLoadNewGraph() before the canvas is overwritten. */