mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-28 08:55:12 +00:00
Compare commits
13 Commits
refactor/m
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b4cd9a152 | ||
|
|
c3dc7f45d4 | ||
|
|
e123f4f36c | ||
|
|
7450ed6823 | ||
|
|
56bd47892b | ||
|
|
c2ef961834 | ||
|
|
78c16368d7 | ||
|
|
8206022982 | ||
|
|
5f2b2f2e87 | ||
|
|
a931acadd3 | ||
|
|
db6b7a315c | ||
|
|
b89940134f | ||
|
|
7ac1cbbd53 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
87
browser_tests/tests/subgraph/subgraphHashValidation.spec.ts
Normal file
87
browser_tests/tests/subgraph/subgraphHashValidation.spec.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -43,7 +43,6 @@ const config: KnipConfig = {
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
],
|
||||
ignore: [
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
1685
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
49
src/components/dialog/rekaPrimeVueBridge.ts
Normal file
49
src/components/dialog/rekaPrimeVueBridge.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
17
src/components/dialog/vRekaZIndex.ts
Normal file
17
src/components/dialog/vRekaZIndex.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
25
src/components/ui/dialog/DialogMaximize.vue
Normal file
25
src/components/ui/dialog/DialogMaximize.vue
Normal 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>
|
||||
@@ -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']>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ModelLoadContext {
|
||||
readonly materialMode: MaterialMode
|
||||
}
|
||||
|
||||
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
|
||||
export interface ModelAdapterCapabilities {
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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]>
|
||||
|
||||
40
src/platform/errorCatalog/catalogI18n.ts
Normal file
40
src/platform/errorCatalog/catalogI18n.ts
Normal 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')
|
||||
)
|
||||
}
|
||||
32
src/platform/errorCatalog/catalogIds.ts
Normal file
32
src/platform/errorCatalog/catalogIds.ts
Normal 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'
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
39
src/platform/errorCatalog/executionErrorResolver.ts
Normal file
39
src/platform/errorCatalog/executionErrorResolver.ts
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
78
src/platform/errorCatalog/missingErrorResolver.ts
Normal file
78
src/platform/errorCatalog/missingErrorResolver.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/platform/errorCatalog/promptErrorResolver.ts
Normal file
72
src/platform/errorCatalog/promptErrorResolver.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
53
src/platform/errorCatalog/runtimeErrorCopy.ts
Normal file
53
src/platform/errorCatalog/runtimeErrorCopy.ts
Normal 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
|
||||
}
|
||||
397
src/platform/errorCatalog/runtimeErrorMatcher.ts
Normal file
397
src/platform/errorCatalog/runtimeErrorMatcher.ts
Normal 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
|
||||
}
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
347
src/platform/errorCatalog/validationErrorResolver.ts
Normal file
347
src/platform/errorCatalog/validationErrorResolver.ts
Normal 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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
98
src/platform/settings/composables/useSettingsDialog.test.ts
Normal file
98
src/platform/settings/composables/useSettingsDialog.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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 -
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
56
src/schemas/subgraphIdSchema.test.ts
Normal file
56
src/schemas/subgraphIdSchema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
10
src/schemas/subgraphIdSchema.ts
Normal file
10
src/schemas/subgraphIdSchema.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
307
src/stores/subgraphNavigationStore.navigateToHash.test.ts
Normal file
307
src/stores/subgraphNavigationStore.navigateToHash.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user