Compare commits
6 Commits
main
...
austin/hov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f39b1dee25 | ||
|
|
1b4456da33 | ||
|
|
82ab1139f1 | ||
|
|
d8707de801 | ||
|
|
d04744f5b1 | ||
|
|
c36dcb06ae |
24
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -109,27 +109,3 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No PostHog references found'
|
||||
|
||||
- name: Scan dist for Customer.io telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Customer.io references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e 'CustomerIoTelemetryProvider' \
|
||||
-e '@customerio/cdp-analytics-browser' \
|
||||
-e 'customerio-gist-web' \
|
||||
-e '(?i)cdp\.customer\.io' \
|
||||
-e 'Comfy\.CustomerIo' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Customer.io references found in dist assets!'
|
||||
echo 'Customer.io must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
interface UploadResponse {
|
||||
name: string
|
||||
subfolder: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
}
|
||||
|
||||
const IMAGE_CANVAS_INDEX = 0
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
|
||||
const successResponse = (name: string): UploadResponse => ({
|
||||
name,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
const fulfillJson = (body: UploadResponse) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
test('Save with drawn mask uploads non-empty mask data', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
expect(observedBodyLength).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const imageDimensions =
|
||||
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
|
||||
const maskDimensions =
|
||||
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
|
||||
|
||||
expect(imageDimensions).not.toBeNull()
|
||||
expect(maskDimensions).not.toBeNull()
|
||||
expect(imageDimensions?.totalPixels).toBe(64 * 64)
|
||||
expect(maskDimensions?.totalPixels).toBe(64 * 64)
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
})
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
@@ -131,14 +131,6 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
'normal',
|
||||
1
|
||||
])
|
||||
|
||||
if (mode.vueNodesEnabled) {
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'denoise')
|
||||
.locator('input')
|
||||
).toHaveValue(/^1(?:\.0+)?$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('Success toast is shown after replacement', async ({
|
||||
|
||||
@@ -177,30 +177,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('does not drag contents when control is held', async ({ comfyPage }) => {
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const initialNodeBounds = await ksampler.boundingBox()
|
||||
expect(initialNodeBounds).toBeTruthy()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await expect
|
||||
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
|
||||
.not.toEqual(groupPos)
|
||||
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -8,7 +8,6 @@ import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -140,46 +139,6 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'Displays previews inside subgraphs received while workflow inactive',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const previewLocator = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
const previewImage = new VueNodeFixture(previewLocator)
|
||||
const subgraphLocator = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
const subgraphNode = new VueNodeFixture(subgraphLocator)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
await expect(previewImage.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await previewImage.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(subgraphNode.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Inject Previews from different tab', async () => {
|
||||
const jobId = await execution.run()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await comfyPage.vueNodes.waitForNodes(7)
|
||||
|
||||
const images = [{ filename: 'example.png', type: 'input' }]
|
||||
execution.executed(jobId, '2:1', { images })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
await expect(subgraphNode.imagePreview.locator('img')).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function countColumns(locator: Locator) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('@vue-nodes tooltips', async () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.settings.setSetting('LiteGraph.Node.TooltipDelay', 0)
|
||||
})
|
||||
|
||||
test('widget value tooltips', async ({ comfyPage }) => {
|
||||
const tooltip = comfyPage.page.locator('.p-tooltip-text')
|
||||
await comfyPage.vueNodes.getWidgetByName('load check', 'ckpt_name').hover()
|
||||
await expect(tooltip, 'displays for combos').toContainText('v1-5-pruned')
|
||||
|
||||
await comfyPage.vueNodes.getWidgetByName('ksampler', 'seed').hover()
|
||||
await expect(tooltip, 'displays for numbers').toContainText('15668')
|
||||
|
||||
await comfyPage.vueNodes.getNodeLocator('6').getByLabel('text').hover()
|
||||
await expect(tooltip).toBeVisible()
|
||||
await expect(tooltip, "doesn't display for prompts").not.toContainText(
|
||||
'purple galaxy bottle'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
5
global.d.ts
vendored
@@ -49,11 +49,6 @@ interface Window {
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Record<string, unknown>
|
||||
customer_io?: {
|
||||
write_key?: string
|
||||
site_id?: string
|
||||
user_id?: string
|
||||
}
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@customerio/cdp-analytics-browser": "catalog:",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
|
||||
137
pnpm-lock.yaml
generated
@@ -21,9 +21,6 @@ catalogs:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.6.2
|
||||
version: 0.6.2
|
||||
'@customerio/cdp-analytics-browser':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
@@ -450,9 +447,6 @@ importers:
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/tailwind-utils
|
||||
'@customerio/cdp-analytics-browser':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.3
|
||||
'@formkit/auto-animate':
|
||||
specifier: 'catalog:'
|
||||
version: 0.9.0
|
||||
@@ -1447,15 +1441,6 @@ packages:
|
||||
peerDependencies:
|
||||
postcss-selector-parser: ^7.0.0
|
||||
|
||||
'@customerio/cdp-analytics-browser@0.5.3':
|
||||
resolution: {integrity: sha512-P4lBz+P2iCekq+DOETiAtSfdMyNVQd7OjXhocjffjPtyBJ0ADhpvYuNZAT9R+q2AFrDTx0m8cuyJAtEG+qiXPQ==}
|
||||
|
||||
'@customerio/cdp-analytics-core@0.5.3':
|
||||
resolution: {integrity: sha512-mjR0dyzsX8UjMAh22bT5ByiIEYwtpnNhc9TlHTk2nGPhFnMctSsn9KuMXD9BmfSFcjdmTPg+iABOq68yyPBPHg==}
|
||||
|
||||
'@customerio/jist@0.1.8':
|
||||
resolution: {integrity: sha512-MPiAm5rxu6+wQiEPwY+nV/5i7y67vJ0TvQpeQrOuATzWC45kgpu4YAJm+RlrpDOq35CK1C3utlPG/wI1F6ycXg==}
|
||||
|
||||
'@cyberalien/svg-utils@1.2.15':
|
||||
resolution: {integrity: sha512-ZbKU6npzW5PNocdoLVJYfKzaP+c/RpT6JUkoaKrW1DOcw6lyXub8XtcNpI3xok6FnyNjS6ZbsrrtjTnS9yeZAQ==}
|
||||
|
||||
@@ -2397,14 +2382,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@lukeed/csprng@1.1.0':
|
||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@lukeed/uuid@2.0.1':
|
||||
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
@@ -3298,18 +3275,6 @@ packages:
|
||||
'@rushstack/ts-command-line@5.3.1':
|
||||
resolution: {integrity: sha512-mid/JIZSJafwy3x9e4v0wVLuAqSSYYErEHV0HXPALYLSBN13YNkR5caOk0hf97lSRKrxhtvQjGaDKSEelR3sMg==}
|
||||
|
||||
'@segment/analytics.js-video-plugins@0.2.1':
|
||||
resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==}
|
||||
|
||||
'@segment/facade@3.4.10':
|
||||
resolution: {integrity: sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==}
|
||||
|
||||
'@segment/isodate-traverse@1.1.1':
|
||||
resolution: {integrity: sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==}
|
||||
|
||||
'@segment/isodate@1.0.3':
|
||||
resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==}
|
||||
|
||||
'@sentry-internal/browser-utils@10.32.1':
|
||||
resolution: {integrity: sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5072,9 +5037,6 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
customerio-gist-web@3.23.2:
|
||||
resolution: {integrity: sha512-oCM7WNEx/3cmEG1qQCKWrMwOtU+h41TTKJICNEb7Wj/1jR6+RJsj3b+3N+5u9TxgvUMusmLFvnVvqshU017eHA==}
|
||||
|
||||
cva@1.0.0-beta.4:
|
||||
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
|
||||
peerDependencies:
|
||||
@@ -6293,10 +6255,6 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
js-cookie@3.0.1:
|
||||
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
js-cookie@3.0.7:
|
||||
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6929,9 +6887,6 @@ packages:
|
||||
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
new-date@1.0.3:
|
||||
resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==}
|
||||
|
||||
nlcst-to-string@4.0.0:
|
||||
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
|
||||
|
||||
@@ -6981,9 +6936,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
obj-case@0.2.1:
|
||||
resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7824,9 +7776,6 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
spark-md5@3.0.2:
|
||||
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -8226,12 +8175,6 @@ packages:
|
||||
unescape-js@1.1.4:
|
||||
resolution: {integrity: sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==}
|
||||
|
||||
unfetch@3.1.2:
|
||||
resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==}
|
||||
|
||||
unfetch@4.2.0:
|
||||
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -8417,10 +8360,6 @@ packages:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@14.0.0:
|
||||
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||
hasBin: true
|
||||
|
||||
valibot@1.2.0:
|
||||
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
|
||||
peerDependencies:
|
||||
@@ -8701,8 +8640,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
vue-component-type-helpers@3.3.4:
|
||||
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -9551,30 +9490,6 @@ snapshots:
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.1
|
||||
|
||||
'@customerio/cdp-analytics-browser@0.5.3':
|
||||
dependencies:
|
||||
'@customerio/cdp-analytics-core': 0.5.3
|
||||
'@lukeed/uuid': 2.0.1
|
||||
'@segment/analytics.js-video-plugins': 0.2.1
|
||||
'@segment/facade': 3.4.10
|
||||
customerio-gist-web: 3.23.2
|
||||
dset: 3.1.4
|
||||
js-cookie: 3.0.1
|
||||
node-fetch: 2.7.0
|
||||
spark-md5: 3.0.2
|
||||
tslib: 2.8.1
|
||||
unfetch: 4.2.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@customerio/cdp-analytics-core@0.5.3':
|
||||
dependencies:
|
||||
'@lukeed/uuid': 2.0.1
|
||||
dset: 3.1.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@customerio/jist@0.1.8': {}
|
||||
|
||||
'@cyberalien/svg-utils@1.2.15':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
@@ -10535,12 +10450,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@lukeed/csprng@1.1.0': {}
|
||||
|
||||
'@lukeed/uuid@2.0.1':
|
||||
dependencies:
|
||||
'@lukeed/csprng': 1.1.0
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.13
|
||||
@@ -11170,23 +11079,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
'@segment/analytics.js-video-plugins@0.2.1':
|
||||
dependencies:
|
||||
unfetch: 3.1.2
|
||||
|
||||
'@segment/facade@3.4.10':
|
||||
dependencies:
|
||||
'@segment/isodate-traverse': 1.1.1
|
||||
inherits: 2.0.4
|
||||
new-date: 1.0.3
|
||||
obj-case: 0.2.1
|
||||
|
||||
'@segment/isodate-traverse@1.1.1':
|
||||
dependencies:
|
||||
'@segment/isodate': 1.0.3
|
||||
|
||||
'@segment/isodate@1.0.3': {}
|
||||
|
||||
'@sentry-internal/browser-utils@10.32.1':
|
||||
dependencies:
|
||||
'@sentry/core': 10.32.1
|
||||
@@ -11431,7 +11323,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.5
|
||||
vue-component-type-helpers: 3.3.4
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -13211,11 +13103,6 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
customerio-gist-web@3.23.2:
|
||||
dependencies:
|
||||
'@customerio/jist': 0.1.8
|
||||
uuid: 14.0.0
|
||||
|
||||
cva@1.0.0-beta.4(typescript@5.9.3):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@@ -14588,8 +14475,6 @@ snapshots:
|
||||
js-cookie: 3.0.7
|
||||
nopt: 7.2.1
|
||||
|
||||
js-cookie@3.0.1: {}
|
||||
|
||||
js-cookie@3.0.7: {}
|
||||
|
||||
js-stringify@1.0.2: {}
|
||||
@@ -15396,10 +15281,6 @@ snapshots:
|
||||
|
||||
neotraverse@0.6.18: {}
|
||||
|
||||
new-date@1.0.3:
|
||||
dependencies:
|
||||
'@segment/isodate': 1.0.3
|
||||
|
||||
nlcst-to-string@4.0.0:
|
||||
dependencies:
|
||||
'@types/nlcst': 2.0.3
|
||||
@@ -15442,8 +15323,6 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
tinyexec: 1.0.4
|
||||
|
||||
obj-case@0.2.1: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -16561,8 +16440,6 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
spark-md5@3.0.2: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
@@ -16977,10 +16854,6 @@ snapshots:
|
||||
dependencies:
|
||||
string.fromcodepoint: 0.2.1
|
||||
|
||||
unfetch@3.1.2: {}
|
||||
|
||||
unfetch@4.2.0: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -17171,8 +17044,6 @@ snapshots:
|
||||
|
||||
uuid@11.1.1: {}
|
||||
|
||||
uuid@14.0.0: {}
|
||||
|
||||
valibot@1.2.0(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -17598,7 +17469,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
vue-component-type-helpers@3.3.4: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -15,7 +15,6 @@ catalog:
|
||||
'@astrojs/sitemap': ^3.7.3
|
||||
'@astrojs/vue': ^6.0.1
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@customerio/cdp-analytics-browser': ^0.5.3
|
||||
'@eslint/js': ^10.0.1
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
:aria-label="
|
||||
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
|
||||
"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
|
||||
collapse && '-rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,31 +1,29 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
@@ -36,7 +34,7 @@
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
@@ -51,7 +49,7 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
@@ -61,29 +59,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
@@ -98,13 +96,13 @@
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
@@ -117,61 +115,60 @@
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-1 py-1">
|
||||
<span
|
||||
class="text-xs font-semibold text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto">
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-4" />
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-testid="missing-node-card" class="px-3">
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal"
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
@@ -80,7 +80,7 @@
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
@@ -89,7 +89,7 @@
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
@@ -122,10 +122,10 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-xs">
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
@@ -150,7 +150,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -163,7 +163,7 @@
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none p-0',
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -190,7 +190,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -199,7 +199,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
@@ -241,7 +241,7 @@ const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
@@ -78,10 +78,6 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
@@ -122,6 +118,9 @@ describe('TabErrors.vue', () => {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
@@ -212,13 +211,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Errors detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
@@ -411,7 +404,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -421,40 +414,6 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts missing models per file when several share one directory', () => {
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-a.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-b.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
}
|
||||
] satisfies MissingModelCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-model')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
@@ -472,7 +431,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
@@ -494,7 +453,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
@@ -536,12 +495,6 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-media')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
@@ -573,7 +526,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -11,62 +11,49 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
v-if="filteredGroups.length === 0"
|
||||
key="empty"
|
||||
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="getGroupCount(group)"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span class="truncate text-destructive-background-hover">
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
@@ -75,7 +62,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
@@ -96,7 +83,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
@@ -109,7 +96,7 @@
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@@ -142,142 +129,140 @@
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div
|
||||
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
|
||||
>
|
||||
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
@@ -319,16 +304,15 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
@@ -372,6 +356,16 @@ const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
@@ -458,28 +452,6 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
function getGroupCount(group: ErrorGroup): number {
|
||||
switch (group.type) {
|
||||
case 'execution':
|
||||
return getExecutionGroupCount(group)
|
||||
case 'missing_node':
|
||||
return missingPackGroups.value.length
|
||||
case 'swap_nodes':
|
||||
return swapNodeGroups.value.length
|
||||
case 'missing_model':
|
||||
return missingModelGroups.value.reduce(
|
||||
(total, modelGroup) => total + modelGroup.models.length,
|
||||
0
|
||||
)
|
||||
case 'missing_media':
|
||||
return countMissingMediaReferences(missingMediaGroups.value)
|
||||
}
|
||||
}
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + getGroupCount(group), 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
@@ -982,7 +982,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
expect(modelGroup?.groupKey).toBe('missing_model')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1098,7 +1098,7 @@ describe('useErrorGroups', () => {
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { render } from '@testing-library/vue'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobState } from '@/types/queue'
|
||||
@@ -20,24 +19,32 @@ type TestTask = {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
const createTestI18n = () =>
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en-US',
|
||||
messages: {
|
||||
'en-US': {
|
||||
queue: {
|
||||
jobList: {
|
||||
undated: 'Undated'
|
||||
}
|
||||
},
|
||||
g: {
|
||||
emDash: '--',
|
||||
untitled: 'Untitled'
|
||||
}
|
||||
}
|
||||
const translations: Record<string, string> = {
|
||||
'queue.jobList.undated': 'Undated',
|
||||
'g.emDash': '--',
|
||||
'g.untitled': 'Untitled'
|
||||
}
|
||||
let localeRef: Ref<string>
|
||||
let tMock: ReturnType<typeof vi.fn>
|
||||
const ensureLocaleMocks = () => {
|
||||
if (!localeRef) {
|
||||
localeRef = ref('en-US') as Ref<string>
|
||||
}
|
||||
if (!tMock) {
|
||||
tMock = vi.fn((key: string) => translations[key] ?? key)
|
||||
}
|
||||
return { localeRef, tMock }
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => {
|
||||
ensureLocaleMocks()
|
||||
return {
|
||||
t: tMock,
|
||||
locale: localeRef
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`)
|
||||
@@ -177,20 +184,13 @@ const createTask = (
|
||||
|
||||
const mountUseJobList = () => {
|
||||
let composable: ReturnType<typeof useJobList>
|
||||
const result = render(
|
||||
{
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useJobList()
|
||||
return {}
|
||||
}
|
||||
},
|
||||
{
|
||||
global: {
|
||||
plugins: [createTestI18n()]
|
||||
}
|
||||
const result = render({
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useJobList()
|
||||
return {}
|
||||
}
|
||||
)
|
||||
})
|
||||
return { ...result, composable: composable! }
|
||||
}
|
||||
|
||||
@@ -215,6 +215,10 @@ const resetStores = () => {
|
||||
totalPercent.value = 0
|
||||
currentNodePercent.value = 0
|
||||
|
||||
ensureLocaleMocks()
|
||||
localeRef.value = 'en-US'
|
||||
tMock.mockClear()
|
||||
|
||||
if (isJobInitializingMock) {
|
||||
vi.mocked(isJobInitializingMock).mockReset()
|
||||
vi.mocked(isJobInitializingMock).mockReturnValue(false)
|
||||
@@ -557,35 +561,6 @@ describe('useJobList', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups terminal jobs without an execution end timestamp by create time', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'failed-before-execution',
|
||||
job: { priority: 1 },
|
||||
mockState: 'failed',
|
||||
createTime: Date.now()
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'completed-without-end-time',
|
||||
job: { priority: 1 },
|
||||
mockState: 'completed',
|
||||
createTime: Date.now() - 1_000
|
||||
})
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
await flush()
|
||||
|
||||
const groups = instance.groupedJobItems.value
|
||||
expect(groups.map((g) => g.label)).toEqual(['Today'])
|
||||
expect(groups[0].items.map((item) => item.id)).toEqual([
|
||||
'failed-before-execution',
|
||||
'completed-without-end-time'
|
||||
])
|
||||
})
|
||||
|
||||
it('groups job items by date label and sorts by total generation time when requested', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
|
||||
@@ -341,7 +341,7 @@ export function useJobList() {
|
||||
for (const { task, state } of searchableTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
ts = task.executionEndTimestamp ?? task.createTime
|
||||
ts = task.executionEndTimestamp
|
||||
} else {
|
||||
ts = task.createTime
|
||||
}
|
||||
|
||||
@@ -1,94 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCopy } from './useCopy'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const copyMocks = vi.hoisted(() => ({
|
||||
copyHandler: undefined as ((event: ClipboardEvent) => unknown) | undefined,
|
||||
canvas: {
|
||||
selectedItems: new Set<object>([{}]),
|
||||
copyToClipboard: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn(
|
||||
(
|
||||
_target: EventTarget,
|
||||
event: string,
|
||||
handler: (event: ClipboardEvent) => unknown
|
||||
) => {
|
||||
if (event === 'copy') copyMocks.copyHandler = handler
|
||||
return vi.fn()
|
||||
}
|
||||
/**
|
||||
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
|
||||
*/
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(
|
||||
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: copyMocks.canvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/eventHelpers', () => ({
|
||||
shouldIgnoreCopyPaste: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const multiChunkPayloadLength = 0x8000 * 6 + 123
|
||||
|
||||
function copySerializedData(serializedData: string): DataTransfer {
|
||||
copyMocks.canvas.copyToClipboard.mockReturnValue(serializedData)
|
||||
|
||||
useCopy()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
const event = new ClipboardEvent('copy', {
|
||||
clipboardData: dataTransfer
|
||||
})
|
||||
const copyHandler = copyMocks.copyHandler
|
||||
expect(copyHandler).toBeDefined()
|
||||
if (!copyHandler) throw new Error('Expected copy handler to be registered')
|
||||
|
||||
expect(() => copyHandler(event)).not.toThrow()
|
||||
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
function readSerializedClipboardMetadata(dataTransfer: DataTransfer): string {
|
||||
const match = dataTransfer
|
||||
.getData('text/html')
|
||||
.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
expect(match).toBeDefined()
|
||||
if (!match) throw new Error('Expected clipboard metadata to be written')
|
||||
|
||||
const binaryString = atob(match)
|
||||
const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0))
|
||||
/**
|
||||
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
|
||||
*/
|
||||
function decodeClipboardData(base64: string): string {
|
||||
const binaryString = atob(base64)
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
describe('useCopy', () => {
|
||||
beforeEach(() => {
|
||||
copyMocks.copyHandler = undefined
|
||||
copyMocks.canvas.copyToClipboard.mockReset()
|
||||
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
|
||||
it('should handle ASCII-only strings', () => {
|
||||
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should write large serialized node data to clipboard metadata', () => {
|
||||
const serializedData = JSON.stringify({
|
||||
it('should handle Chinese characters in localized_name', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Japanese characters', () => {
|
||||
const original = '{"localized_name":"画像を読み込む"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Korean characters', () => {
|
||||
const original = '{"localized_name":"이미지 불러오기"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle mixed ASCII and Unicode characters', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle emoji characters', () => {
|
||||
const original = '{"title":"Test Node 🎨🖼️"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const original = ''
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle complex node data with multiple Unicode fields', () => {
|
||||
const original = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Subgraph',
|
||||
title: 'Large Subgraph',
|
||||
localized_name: '이미지 그룹 图像 🎨',
|
||||
payload: 'x'.repeat(multiChunkPayloadLength)
|
||||
type: 'LoadImage',
|
||||
localized_name: '图像',
|
||||
inputs: [{ localized_name: '图片', name: 'image' }],
|
||||
outputs: [{ localized_name: '输出', name: 'output' }]
|
||||
}
|
||||
],
|
||||
groups: [{ title: '预处理组 🔧' }],
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: []
|
||||
links: []
|
||||
})
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
|
||||
})
|
||||
|
||||
const dataTransfer = copySerializedData(serializedData)
|
||||
it('should produce valid base64 output', () => {
|
||||
const original = '{"localized_name":"中文测试"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
// Base64 should only contain valid characters
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
|
||||
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(serializedData)
|
||||
it('should fail with plain btoa for non-Latin1 characters', () => {
|
||||
const original = '{"localized_name":"图像"}'
|
||||
// This demonstrates why we need TextEncoder - plain btoa fails
|
||||
expect(() => btoa(original)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,29 +7,6 @@ const clipboardHTMLWrapper = [
|
||||
'<meta charset="utf-8"><div><span data-metadata="',
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
]
|
||||
const clipboardByteChunkSize = 0x8000
|
||||
|
||||
function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
const chunks: string[] = []
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < bytes.length;
|
||||
offset += clipboardByteChunkSize
|
||||
) {
|
||||
chunks.push(
|
||||
String.fromCharCode(
|
||||
...bytes.subarray(offset, offset + clipboardByteChunkSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(bytesToBinaryString(new TextEncoder().encode(data)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a handler on copy that serializes selected nodes to JSON
|
||||
@@ -46,16 +23,17 @@ export const useCopy = () => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (canvas?.selectedItems) {
|
||||
const serializedData = canvas.copyToClipboard()
|
||||
try {
|
||||
const base64Data = encodeClipboardData(serializedData)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
// Use TextEncoder to handle Unicode characters properly
|
||||
const base64Data = btoa(
|
||||
String.fromCharCode(
|
||||
...Array.from(new TextEncoder().encode(serializedData))
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
return false
|
||||
|
||||
@@ -119,23 +119,6 @@ describe('load3dLazy', () => {
|
||||
expect(spec.upload_subfolder).toBe('3d')
|
||||
})
|
||||
|
||||
it('injects mesh_upload spec flags into the model_file widget for Load3DAdvanced nodes', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Load3DAdvanced', {
|
||||
input: {
|
||||
required: { model_file: ['STRING', {}] }
|
||||
}
|
||||
} as Partial<ComfyNodeDef>)
|
||||
|
||||
await hook({} as typeof LGraphNode, nodeData)
|
||||
|
||||
const spec = (
|
||||
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
|
||||
)[1]
|
||||
expect(spec.mesh_upload).toBe(true)
|
||||
expect(spec.upload_subfolder).toBe('3d')
|
||||
})
|
||||
|
||||
it('does not throw when a Load3D node has no model_file widget spec', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Load3D', {
|
||||
|
||||
@@ -61,12 +61,18 @@ useExtensionService().registerExtension({
|
||||
if (isLoad3dNode(nodeData.name)) {
|
||||
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
|
||||
// Load3D's model_file as a mesh upload widget without hardcoding.
|
||||
if (nodeData.name === 'Load3D' || nodeData.name === 'Load3DAdvanced') {
|
||||
if (nodeData.name === 'Load3D') {
|
||||
const modelFile = nodeData.input?.required?.model_file
|
||||
if (modelFile?.[1]) {
|
||||
modelFile[1].mesh_upload = true
|
||||
modelFile[1].upload_subfolder = '3d'
|
||||
}
|
||||
} else if (nodeData.name === 'Load3DAdvanced') {
|
||||
const modelFile = nodeData.input?.required?.model_file
|
||||
if (modelFile?.[1]) {
|
||||
modelFile[1].mesh_upload = true
|
||||
modelFile[1].upload_subfolder = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const { app } = vi.hoisted(() => ({
|
||||
app: {
|
||||
registerExtension: vi.fn(),
|
||||
graph: undefined as unknown as LGraph
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app }))
|
||||
|
||||
type BeforeRegisterNodeDef = NonNullable<
|
||||
ComfyExtension['beforeRegisterNodeDef']
|
||||
>
|
||||
|
||||
interface FilenamePrefixWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
serializeValue?: () => string
|
||||
}
|
||||
|
||||
async function loadExtension(): Promise<ComfyExtension> {
|
||||
vi.resetModules()
|
||||
app.registerExtension.mockClear()
|
||||
await import('./saveImageExtraOutput')
|
||||
return app.registerExtension.mock.calls[0][0] as ComfyExtension
|
||||
}
|
||||
|
||||
async function createNodeWithFilenamePrefix(
|
||||
nodeName: string,
|
||||
prefix: string
|
||||
): Promise<FilenamePrefixWidget> {
|
||||
const ext = await loadExtension()
|
||||
|
||||
const nodeType = {
|
||||
prototype: {}
|
||||
} as unknown as Parameters<BeforeRegisterNodeDef>[0]
|
||||
const nodeData = { name: nodeName } as ComfyNodeDef
|
||||
|
||||
await ext.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<BeforeRegisterNodeDef>[2]
|
||||
)
|
||||
|
||||
const widget: FilenamePrefixWidget = {
|
||||
name: 'filename_prefix',
|
||||
value: prefix
|
||||
}
|
||||
const node = { widgets: [widget] }
|
||||
const proto = nodeType.prototype as { onNodeCreated?: () => void }
|
||||
proto.onNodeCreated!.call(node)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
describe('Comfy.SaveImageExtraOutput', () => {
|
||||
beforeEach(() => {
|
||||
const graph = new LGraph()
|
||||
graph.add({
|
||||
properties: { 'Node name for S&R': 'Sampler' },
|
||||
widgets: [{ name: 'seed', value: 12345 }]
|
||||
} as unknown as LGraphNode)
|
||||
app.graph = graph
|
||||
})
|
||||
|
||||
it.each([
|
||||
'SaveImage',
|
||||
'SaveImageAdvanced',
|
||||
'SaveSVGNode',
|
||||
'SaveVideo',
|
||||
'SaveAnimatedWEBP',
|
||||
'SaveWEBM',
|
||||
'SaveAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
'ModelSave',
|
||||
'LoraSave',
|
||||
'SaveLatent'
|
||||
])(
|
||||
'resolves text replacements in the filename_prefix of %s on serialize',
|
||||
async (nodeName) => {
|
||||
const widget = await createNodeWithFilenamePrefix(
|
||||
nodeName,
|
||||
'ComfyUI_%Sampler.seed%'
|
||||
)
|
||||
|
||||
expect(widget.serializeValue!()).toBe('ComfyUI_12345')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -6,15 +6,10 @@ import { app } from '../../scripts/app'
|
||||
|
||||
const saveNodeTypes = new Set([
|
||||
'SaveImage',
|
||||
'SaveImageAdvanced',
|
||||
'SaveSVGNode',
|
||||
'SaveVideo',
|
||||
'SaveAnimatedWEBP',
|
||||
'SaveWEBM',
|
||||
'SaveAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
|
||||
@@ -8931,20 +8931,71 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all nodes that are children of groups in the selection
|
||||
*/
|
||||
private collectNodesInGroups(items: Set<Positionable>): Set<LGraphNode> {
|
||||
const nodesInGroups = new Set<LGraphNode>()
|
||||
for (const item of items) {
|
||||
if (item instanceof LGraphGroup) {
|
||||
for (const child of item._children) {
|
||||
if (child instanceof LGraphNode) {
|
||||
nodesInGroups.add(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodesInGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* Move group children (both nodes and non-nodes)
|
||||
*/
|
||||
private moveGroupChildren(
|
||||
group: LGraphGroup,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
|
||||
): void {
|
||||
for (const child of group._children) {
|
||||
if (child instanceof LGraphNode) {
|
||||
const node = child as LGraphNode
|
||||
nodesToMove.push({
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else if (!(child instanceof LGraphGroup)) {
|
||||
// Non-node, non-group children (reroutes, etc.)
|
||||
// Skip groups here - they're already in allItems and will be
|
||||
// processed in the main loop of moveChildNodesInGroupVueMode
|
||||
child.move(deltaX, deltaY, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveChildNodesInGroupVueMode(
|
||||
allItems: Set<Positionable>,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
) {
|
||||
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
|
||||
const nodesToMove: NewNodePosition[] = []
|
||||
|
||||
// First, collect all the moves we need to make
|
||||
for (const item of allItems) {
|
||||
if (item instanceof LGraphNode) {
|
||||
const isNode = item instanceof LGraphNode
|
||||
if (isNode) {
|
||||
const node = item as LGraphNode
|
||||
if (nodesInMovingGroups.has(node)) {
|
||||
continue
|
||||
}
|
||||
nodesToMove.push({
|
||||
node: item,
|
||||
newPos: this.calculateNewPosition(item, deltaX, deltaY)
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else if (item instanceof LGraphGroup) {
|
||||
item.move(deltaX, deltaY, true)
|
||||
this.moveGroupChildren(item, deltaX, deltaY, nodesToMove)
|
||||
} else {
|
||||
// Other items (reroutes, etc.)
|
||||
item.move(deltaX, deltaY, true)
|
||||
|
||||
@@ -3627,10 +3627,6 @@
|
||||
"hideAdvancedShort": "Hide advanced",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"errorsDetected": "Error detected | Errors detected",
|
||||
"resolveBeforeRun": "Resolve before running the workflow",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
|
||||
@@ -18,10 +18,7 @@ import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
getAssetStoredFilename
|
||||
} from '../utils/assetMetadataUtils'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import { getAssetType } from '../utils/assetTypeUtil'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
|
||||
@@ -304,7 +301,10 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const assetType = getAssetType(targetAsset, 'input')
|
||||
|
||||
const filename = getAssetStoredFilename(targetAsset)
|
||||
// In Cloud mode, use the content hash (the actual stored filename).
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = targetAsset.hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
|
||||
|
||||
// Create annotated path for the asset
|
||||
const annotated = createAnnotatedPath(
|
||||
@@ -445,7 +445,10 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const assetType = getAssetType(asset, 'input')
|
||||
|
||||
const filename = getAssetStoredFilename(asset)
|
||||
// In Cloud mode, use the content hash (the actual stored filename).
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = asset.hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : asset.name
|
||||
|
||||
const annotated = createAnnotatedPath(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
@@ -12,23 +12,11 @@ import {
|
||||
getAssetFilename,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getSourceName
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
isCloudRef: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
get isCloud() {
|
||||
return isCloudRef.value
|
||||
}
|
||||
}))
|
||||
|
||||
describe('assetMetadataUtils', () => {
|
||||
const mockAsset: AssetItem = {
|
||||
id: 'test-id',
|
||||
@@ -307,28 +295,6 @@ describe('assetMetadataUtils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetStoredFilename', () => {
|
||||
afterEach(() => {
|
||||
isCloudRef.value = true
|
||||
})
|
||||
|
||||
it('returns the content hash on cloud when present', () => {
|
||||
isCloudRef.value = true
|
||||
expect(getAssetStoredFilename(mockAsset)).toBe('hash123')
|
||||
})
|
||||
|
||||
it('falls back to name on cloud when no hash is present', () => {
|
||||
isCloudRef.value = true
|
||||
const asset = { ...mockAsset, hash: undefined }
|
||||
expect(getAssetStoredFilename(asset)).toBe('test-model')
|
||||
})
|
||||
|
||||
it('returns name on OSS regardless of hash', () => {
|
||||
isCloudRef.value = false
|
||||
expect(getAssetStoredFilename(mockAsset)).toBe('test-model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetFilename', () => {
|
||||
it('returns user_metadata.filename when present', () => {
|
||||
const asset = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isCivitaiUrl } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
@@ -172,22 +171,6 @@ export function getAssetFilename(asset: AssetItem): string {
|
||||
return getStringProperty(asset, 'filename') ?? asset.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the filename that addresses an asset's *bytes* in storage — use
|
||||
* this to build the path a backend resolves to a real file (the
|
||||
* `createAnnotatedPath` input behind `/view` requests and widget values),
|
||||
* never to show the user. Cloud is content-addressed, so it returns the
|
||||
* content hash (`hash`); OSS is filesystem-backed, so it returns `name`.
|
||||
*
|
||||
* For a human-readable label use {@link getAssetDisplayFilename}; for a
|
||||
* serialized identifier (matching, validation) use {@link getAssetFilename}.
|
||||
*
|
||||
* TODO(BE-933/934): collapse to `asset.file_path ?? asset.name`.
|
||||
*/
|
||||
export function getAssetStoredFilename(asset: AssetItem): string {
|
||||
return isCloud && asset.hash ? asset.hash : asset.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the human-readable filename to render in UI surfaces.
|
||||
* Fallback chain: user_metadata.filename → metadata.filename →
|
||||
|
||||
@@ -143,7 +143,7 @@ const { t } = useI18n()
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem]/none font-semibold text-base-foreground"
|
||||
class="text-[2rem] leading-none font-semibold text-base-foreground"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
|
||||
@@ -1394,7 +1394,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Missing Node Packs',
|
||||
displayTitle: 'Missing Node Packs (1)',
|
||||
displayMessage: 'Install missing packs to use this workflow.',
|
||||
toastTitle: 'Missing node: FooNode',
|
||||
toastMessage:
|
||||
@@ -1410,7 +1410,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Unsupported Node Packs',
|
||||
displayTitle: 'Unsupported Node Packs (1)',
|
||||
displayMessage:
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
|
||||
toastTitle: "FooNode isn't available on Cloud",
|
||||
@@ -1471,7 +1471,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: 'Swap Nodes',
|
||||
displayTitle: 'Swap Nodes (1)',
|
||||
displayMessage: 'Some nodes can be replaced with alternatives',
|
||||
toastTitle: 'OldNode can be replaced',
|
||||
toastMessage: 'Replace it with NewNode from the error panel.'
|
||||
@@ -1520,7 +1520,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayMessage: 'Download a model, or open the node to replace it.',
|
||||
toastTitle: 'sdxl.safetensors is missing',
|
||||
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
|
||||
@@ -1535,7 +1535,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: "sdxl.safetensors isn't available on Cloud",
|
||||
toastMessage: "This model isn't supported. Choose a different one."
|
||||
@@ -1573,7 +1573,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: 'Missing Inputs',
|
||||
displayTitle: 'Missing Inputs (1)',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.'
|
||||
@@ -1707,7 +1707,7 @@ describe('errorMessageResolver', () => {
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Missing Inputs',
|
||||
displayTitle: 'Missing Inputs (2)',
|
||||
toastTitle: 'Missing media inputs',
|
||||
toastMessage:
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
|
||||
@@ -6,6 +6,10 @@ import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
function formatCountTitle(title: string, count: number): string {
|
||||
return `${title} (${count})`
|
||||
}
|
||||
|
||||
function formatNodeTypeName(nodeType: string): string | null {
|
||||
const trimmed = nodeType.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -340,12 +344,15 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_node':
|
||||
return {
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: source.isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
displayTitle: formatCountTitle(
|
||||
source.isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingNodeDisplayMessage(source),
|
||||
toastTitle: resolveMissingNodeToastTitle(source),
|
||||
toastMessage: resolveMissingNodeToastMessage(source)
|
||||
@@ -353,7 +360,10 @@ export function resolveMissingErrorMessage(
|
||||
case 'swap_nodes':
|
||||
return {
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
displayTitle: formatCountTitle(
|
||||
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveSwapNodeDisplayMessage(),
|
||||
toastTitle: resolveSwapNodeToastTitle(source),
|
||||
toastMessage: resolveSwapNodeToastMessage(source)
|
||||
@@ -361,9 +371,12 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_model':
|
||||
return {
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: st(
|
||||
'rightSidePanel.missingModels.missingModelsTitle',
|
||||
'Missing Models'
|
||||
displayTitle: formatCountTitle(
|
||||
st(
|
||||
'rightSidePanel.missingModels.missingModelsTitle',
|
||||
'Missing Models'
|
||||
),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingModelDisplayMessage(source),
|
||||
toastTitle: resolveMissingModelToastTitle(source),
|
||||
@@ -372,9 +385,9 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_media':
|
||||
return {
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: st(
|
||||
'rightSidePanel.missingMedia.missingMediaTitle',
|
||||
'Missing Inputs'
|
||||
displayTitle: formatCountTitle(
|
||||
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingMediaDisplayMessage(),
|
||||
toastTitle: resolveMissingMediaToastTitle(source),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="px-4 pb-2">
|
||||
<TransitionGroup
|
||||
tag="ul"
|
||||
name="list-scale"
|
||||
@@ -15,7 +15,7 @@
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="emit('locateNode', item.nodeId)"
|
||||
>
|
||||
{{ item.displayItemLabel }}
|
||||
@@ -25,7 +25,7 @@
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.displayItemLabel
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="px-4 pb-2">
|
||||
<div
|
||||
v-if="importableModelRows.length > 0"
|
||||
data-testid="missing-model-importable-rows"
|
||||
class="flex flex-col gap-1 overflow-hidden"
|
||||
class="flex flex-col gap-1 overflow-hidden py-2"
|
||||
>
|
||||
<MissingModelRow
|
||||
v-for="row in importableModelRows"
|
||||
@@ -19,7 +19,7 @@
|
||||
<div
|
||||
v-if="unsupportedModelRows.length > 0"
|
||||
data-testid="missing-model-import-not-supported-section"
|
||||
class="flex flex-col gap-1 border-t border-secondary-background pt-3"
|
||||
class="flex flex-col gap-1 border-t border-interface-stroke pt-3"
|
||||
>
|
||||
<div class="mb-1">
|
||||
<p class="m-0 text-sm font-semibold text-warning-background">
|
||||
@@ -49,7 +49,7 @@
|
||||
data-testid="missing-model-download-all"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 min-w-0 flex-1 rounded-md text-xs"
|
||||
class="h-8 min-w-0 flex-1 rounded-lg text-sm"
|
||||
@click="downloadAllModels"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
|
||||
|
||||
@@ -12,27 +12,27 @@
|
||||
: t('rightSidePanel.missingModels.expandNodes')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="handleToggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<span class="flex min-w-0 items-center gap-1 text-xs/tight">
|
||||
<span class="block min-w-0 text-sm/tight">
|
||||
<button
|
||||
v-if="hasModelLabelControl"
|
||||
ref="modelLabelControl"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 min-w-0 cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
:title="displayModelName"
|
||||
@click="handleModelLabelClick"
|
||||
>
|
||||
@@ -40,7 +40,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 font-normal wrap-break-word text-base-foreground"
|
||||
class="font-normal wrap-break-word text-base-foreground"
|
||||
:title="displayModelName"
|
||||
>
|
||||
{{ displayModelName }}
|
||||
@@ -48,14 +48,14 @@
|
||||
<span
|
||||
v-if="hasMultipleReferences"
|
||||
data-testid="missing-model-reference-count"
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
class="ml-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected align-middle text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ model.referencingNodes.length }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="linkLabel"
|
||||
:title="linkLabel"
|
||||
@click="copyModelLink"
|
||||
@@ -82,7 +82,7 @@
|
||||
data-testid="missing-model-import"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="showUploadDialog"
|
||||
>
|
||||
{{ t('g.import') }}
|
||||
@@ -123,7 +123,7 @@
|
||||
data-testid="missing-model-download"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:aria-label="`${t('g.download')} ${model.name}`"
|
||||
@click="handleDownload"
|
||||
>
|
||||
@@ -137,7 +137,7 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="handleLocatePrimary"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
@@ -149,7 +149,7 @@
|
||||
v-if="showReferenceList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none p-0',
|
||||
'm-0 list-none space-y-0.5 p-0',
|
||||
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -159,10 +159,10 @@
|
||||
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-h-8 min-w-0 items-center gap-2">
|
||||
<div class="flex min-h-6 min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
{{
|
||||
@@ -174,7 +174,7 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="ml-auto size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
|
||||
@@ -3,26 +3,21 @@
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<Button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="swap-node-group-expand"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -32,7 +27,7 @@
|
||||
<button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
:title="group.type"
|
||||
:aria-label="titleToggleAriaLabel"
|
||||
:aria-expanded="expanded"
|
||||
@@ -43,7 +38,7 @@
|
||||
<button
|
||||
v-else-if="primaryLocatableNodeType"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
:title="group.type"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -51,7 +46,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal text-base-foreground"
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
|
||||
:title="group.type"
|
||||
>
|
||||
{{ group.type }}
|
||||
@@ -60,7 +55,7 @@
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="swap-node-group-count"
|
||||
role="img"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
@@ -85,7 +80,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="handleReplaceNode"
|
||||
>
|
||||
<i
|
||||
@@ -101,7 +96,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="locateNodeLabel"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -121,14 +116,14 @@
|
||||
<button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -137,7 +132,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="locateNodeLabel"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="mt-2 px-4 pb-2">
|
||||
<SwapNodeGroupRow
|
||||
v-for="group in swapNodeGroups"
|
||||
:key="group.type"
|
||||
|
||||
@@ -355,13 +355,8 @@ describe('useNodeReplacement', () => {
|
||||
{ name: 'largest_size', link: null }
|
||||
],
|
||||
[{ name: 'IMAGE', links: null }],
|
||||
[
|
||||
{ name: 'largest_size', value: 0 },
|
||||
{ name: 'face_point_size', value: 1 }
|
||||
]
|
||||
[{ name: 'largest_size', value: 0 }]
|
||||
)
|
||||
const setNodeId = vi.fn()
|
||||
Object.assign(newNode.widgets![1], { setNodeId })
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
|
||||
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
@@ -379,8 +374,8 @@ describe('useNodeReplacement', () => {
|
||||
})
|
||||
])
|
||||
|
||||
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
|
||||
expect(newNode.widgets![0].value).toBe(512)
|
||||
expect(setNodeId).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should skip replacement when new node type is not registered', () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
|
||||
import { t } from '@/i18n'
|
||||
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -171,9 +170,6 @@ function replaceWithMapping(
|
||||
nodeGraph._nodes[idx] = newNode
|
||||
newNode.graph = nodeGraph
|
||||
nodeGraph._nodes_by_id[newNode.id] = newNode
|
||||
for (const widget of newNode.widgets ?? []) {
|
||||
if (isNodeBindable(widget)) widget.setNodeId(newNode.id)
|
||||
}
|
||||
|
||||
const serialized = node.last_serialization ?? node.serialize()
|
||||
|
||||
|
||||
@@ -82,11 +82,6 @@ export type RemoteConfig = {
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Partial<PostHogConfig>
|
||||
customer_io?: {
|
||||
write_key?: string
|
||||
site_id?: string
|
||||
user_id?: string
|
||||
}
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
|
||||
@@ -53,16 +53,13 @@ describe('useSettingsDialog', () => {
|
||||
isCloudRef.value = false
|
||||
})
|
||||
|
||||
it("show() opens the Reka renderer with size 'full' and 1280px content sizing", () => {
|
||||
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-[1280px]')
|
||||
expect(args.dialogComponentProps.contentClass).not.toContain(
|
||||
'max-w-[960px]'
|
||||
)
|
||||
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
|
||||
})
|
||||
|
||||
|
||||
@@ -8,9 +8,8 @@ import type { SettingPanelType } from '@/platform/settings/types'
|
||||
|
||||
const DIALOG_KEY = 'global-settings'
|
||||
|
||||
// The redesigned Settings dialog is 1280px wide (DES 3253-16079).
|
||||
const SETTINGS_CONTENT_CLASS =
|
||||
'w-[90vw] max-w-[1280px] sm:max-w-[1280px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
|
||||
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
|
||||
|
||||
export function useSettingsDialog() {
|
||||
const dialogService = useDialogService()
|
||||
|
||||
@@ -26,16 +26,14 @@ export async function initTelemetry(): Promise<void> {
|
||||
{ GtmTelemetryProvider },
|
||||
{ ImpactTelemetryProvider },
|
||||
{ PostHogTelemetryProvider },
|
||||
{ ClickHouseTelemetryProvider },
|
||||
{ CustomerIoTelemetryProvider }
|
||||
{ ClickHouseTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
import('./providers/cloud/MixpanelTelemetryProvider'),
|
||||
import('./providers/cloud/GtmTelemetryProvider'),
|
||||
import('./providers/cloud/ImpactTelemetryProvider'),
|
||||
import('./providers/cloud/PostHogTelemetryProvider'),
|
||||
import('./providers/cloud/ClickHouseTelemetryProvider'),
|
||||
import('./providers/cloud/CustomerIoTelemetryProvider')
|
||||
import('./providers/cloud/ClickHouseTelemetryProvider')
|
||||
])
|
||||
|
||||
const registry = new TelemetryRegistry()
|
||||
@@ -44,7 +42,6 @@ export async function initTelemetry(): Promise<void> {
|
||||
registry.registerProvider(new ImpactTelemetryProvider())
|
||||
registry.registerProvider(new PostHogTelemetryProvider())
|
||||
registry.registerProvider(new ClickHouseTelemetryProvider())
|
||||
registry.registerProvider(new CustomerIoTelemetryProvider())
|
||||
|
||||
setTelemetryRegistry(registry)
|
||||
})()
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const analytics = {
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
register: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
let resolvedCb: ((user: { id: string }) => void) | undefined
|
||||
let logoutCb: (() => void) | undefined
|
||||
return {
|
||||
analytics,
|
||||
load: vi.fn(() => analytics),
|
||||
inAppPlugin: vi.fn(() => ({ name: 'Customer.io In-App Plugin' })),
|
||||
onUserResolved: vi.fn((cb: (user: { id: string }) => void) => {
|
||||
resolvedCb = cb
|
||||
}),
|
||||
onUserLogout: vi.fn((cb: () => void) => {
|
||||
logoutCb = cb
|
||||
}),
|
||||
resolveUser: (id: string) => resolvedCb?.({ id }),
|
||||
logoutUser: () => logoutCb?.()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@customerio/cdp-analytics-browser', () => ({
|
||||
AnalyticsBrowser: { load: hoisted.load },
|
||||
InAppPlugin: hoisted.inAppPlugin
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: hoisted.onUserResolved,
|
||||
onUserLogout: hoisted.onUserLogout
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
CustomerIoTelemetryProvider,
|
||||
EVENT_SOURCE
|
||||
} from './CustomerIoTelemetryProvider'
|
||||
|
||||
const WRITE_KEY = 'cdp_test_write_key'
|
||||
const SITE_ID = 'f87746f8c188c8ddcf41'
|
||||
const SOURCE = { event_source: EVENT_SOURCE }
|
||||
|
||||
function createProvider(
|
||||
config: Partial<typeof window.__CONFIG__> = {
|
||||
customer_io: { write_key: WRITE_KEY, site_id: SITE_ID }
|
||||
}
|
||||
): CustomerIoTelemetryProvider {
|
||||
window.__CONFIG__ = config as typeof window.__CONFIG__
|
||||
return new CustomerIoTelemetryProvider()
|
||||
}
|
||||
|
||||
describe('CustomerIoTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.load.mockReturnValue(hoisted.analytics)
|
||||
hoisted.analytics.register.mockResolvedValue(undefined)
|
||||
window.__CONFIG__ = {} as typeof window.__CONFIG__
|
||||
})
|
||||
|
||||
it('loads the client and registers the in-app plugin with the site id', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.load).toHaveBeenCalledWith({ writeKey: WRITE_KEY })
|
||||
expect(hoisted.inAppPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ siteId: SITE_ID })
|
||||
)
|
||||
expect(hoisted.analytics.register).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not initialize without a write key', async () => {
|
||||
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackWorkflowExecution()
|
||||
expect(hoisted.load).not.toHaveBeenCalled()
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not initialize without a site id', async () => {
|
||||
createProvider({ customer_io: { write_key: WRITE_KEY } })
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identifies the person by uid only on auth resolve', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.resolveUser('test-uid-7f3a9c')
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('test-uid-7f3a9c')
|
||||
})
|
||||
|
||||
it('identifies with the configured user_id override without waiting for auth', async () => {
|
||||
createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid')
|
||||
expect(hoisted.onUserResolved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identifies before flushing events buffered before the SDK loads', async () => {
|
||||
const provider = createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const identifyOrder = hoisted.analytics.identify.mock.invocationCallOrder[0]
|
||||
const trackOrder = hoisted.analytics.track.mock.invocationCallOrder[0]
|
||||
expect(identifyOrder).toBeLessThan(trackOrder)
|
||||
})
|
||||
|
||||
it('resets on logout', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
const DIRECT_EVENTS: Array<{
|
||||
event: string
|
||||
invoke: (p: CustomerIoTelemetryProvider) => void
|
||||
expected: Record<string, unknown>
|
||||
}> = [
|
||||
{
|
||||
event: 'app:user_auth_completed',
|
||||
invoke: (p) => p.trackAuth({ method: 'google', is_new_user: true }),
|
||||
expected: { ...SOURCE, method: 'google', is_new_user: true }
|
||||
},
|
||||
{
|
||||
event: 'app:subscription_required_modal_opened',
|
||||
invoke: (p) =>
|
||||
p.trackSubscription('modal_opened', { current_tier: 'pro' }),
|
||||
expected: { ...SOURCE, current_tier: 'pro' }
|
||||
},
|
||||
{
|
||||
event: 'app:subscribe_now_button_clicked',
|
||||
invoke: (p) => p.trackSubscription('subscribe_clicked'),
|
||||
expected: SOURCE
|
||||
},
|
||||
{
|
||||
event: 'app:add_api_credit_button_clicked',
|
||||
invoke: (p) => p.trackAddApiCreditButtonClicked(),
|
||||
expected: SOURCE
|
||||
},
|
||||
{
|
||||
event: 'execution_start',
|
||||
invoke: (p) => p.trackWorkflowExecution(),
|
||||
expected: SOURCE
|
||||
},
|
||||
{
|
||||
event: 'execution_success',
|
||||
invoke: (p) => p.trackExecutionSuccess({ jobId: 'job-abc' }),
|
||||
expected: { ...SOURCE, jobId: 'job-abc' }
|
||||
},
|
||||
{
|
||||
event: 'app:template_workflow_opened',
|
||||
invoke: (p) => p.trackTemplate({ workflow_name: 'flux-dev' }),
|
||||
expected: { ...SOURCE, workflow_name: 'flux-dev' }
|
||||
},
|
||||
{
|
||||
event: 'app:template_library_opened',
|
||||
invoke: (p) => p.trackTemplateLibraryOpened({ source: 'sidebar' }),
|
||||
expected: { ...SOURCE, source: 'sidebar' }
|
||||
},
|
||||
{
|
||||
event: 'app:share_flow',
|
||||
invoke: (p) =>
|
||||
p.trackShareFlow({
|
||||
step: 'dialog_opened',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
}),
|
||||
expected: {
|
||||
...SOURCE,
|
||||
step: 'dialog_opened',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
it.for(DIRECT_EVENTS)(
|
||||
'sends $event with its metadata merged under the event_source tag',
|
||||
async ({ event, invoke, expected }) => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
invoke(provider)
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('flushes events buffered before load once, in order', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['execution_start', SOURCE],
|
||||
['app:add_api_credit_button_clicked', SOURCE]
|
||||
])
|
||||
})
|
||||
|
||||
it('disables tracking when the SDK fails to load', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.load.mockImplementation(() => {
|
||||
throw new Error('network down')
|
||||
})
|
||||
|
||||
const provider = createProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps tracking after an individual event fails to send', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.analytics.track.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('network blip'))
|
||||
)
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() =>
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to track Customer.io event:',
|
||||
expect.any(Error)
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { AnalyticsBrowser } from '@customerio/cdp-analytics-browser'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
TelemetryEventProperties,
|
||||
TelemetryProvider,
|
||||
TemplateLibraryMetadata,
|
||||
TemplateMetadata
|
||||
} from '../../types'
|
||||
|
||||
export const EVENT_SOURCE = 'web-sdk'
|
||||
|
||||
interface QueuedEvent {
|
||||
event: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer.io (Data Pipelines) Telemetry Provider - Cloud Build Implementation
|
||||
*
|
||||
* CRITICAL: OSS Build Safety
|
||||
* Only registered from the cloud-only initTelemetry, so the file and its SDK
|
||||
* import are tree-shaken away in OSS/desktop builds.
|
||||
*/
|
||||
export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
private analytics: AnalyticsBrowser | null = null
|
||||
private isEnabled = true
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
|
||||
constructor() {
|
||||
const {
|
||||
write_key: writeKey,
|
||||
site_id: siteId,
|
||||
user_id: userIdOverride
|
||||
} = window.__CONFIG__?.customer_io ?? {}
|
||||
if (!writeKey || !siteId) {
|
||||
this.isEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
void import('@customerio/cdp-analytics-browser')
|
||||
.then(({ AnalyticsBrowser, InAppPlugin }) => {
|
||||
const analytics = AnalyticsBrowser.load({ writeKey })
|
||||
void analytics.register(
|
||||
InAppPlugin({
|
||||
siteId,
|
||||
events: null,
|
||||
anonymousInApp: false,
|
||||
_env: undefined,
|
||||
_logging: undefined,
|
||||
colorScheme: 'system'
|
||||
})
|
||||
)
|
||||
this.analytics = analytics
|
||||
|
||||
const currentUser = useCurrentUser()
|
||||
if (userIdOverride) {
|
||||
void analytics.identify(userIdOverride)
|
||||
} else {
|
||||
currentUser.onUserResolved((user) => void analytics.identify(user.id))
|
||||
}
|
||||
currentUser.onUserLogout(() => void analytics.reset())
|
||||
|
||||
this.flushQueue()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load Customer.io:', error)
|
||||
this.isEnabled = false
|
||||
this.eventQueue = []
|
||||
})
|
||||
}
|
||||
|
||||
private send(event: string, properties: Record<string, unknown>): void {
|
||||
void this.analytics?.track(event, properties)?.catch((error) => {
|
||||
console.error('Failed to track Customer.io event:', error)
|
||||
})
|
||||
}
|
||||
|
||||
private track(event: string, metadata?: TelemetryEventProperties): void {
|
||||
if (!this.isEnabled) return
|
||||
const properties = { ...metadata, event_source: EVENT_SOURCE }
|
||||
if (this.analytics) {
|
||||
this.send(event, properties)
|
||||
} else {
|
||||
this.eventQueue.push({ event, properties })
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueue(): void {
|
||||
if (!this.analytics) return
|
||||
for (const { event, properties } of this.eventQueue) {
|
||||
this.send(event, properties)
|
||||
}
|
||||
this.eventQueue = []
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.track(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
event: 'modal_opened' | 'subscribe_clicked',
|
||||
metadata?: SubscriptionMetadata
|
||||
): void {
|
||||
this.track(
|
||||
event === 'modal_opened'
|
||||
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
|
||||
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED,
|
||||
metadata
|
||||
)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.track(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
this.track(TelemetryEvents.EXECUTION_START)
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.track(TelemetryEvents.EXECUTION_SUCCESS, metadata)
|
||||
}
|
||||
|
||||
trackTemplate(metadata: TemplateMetadata): void {
|
||||
this.track(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
|
||||
this.track(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.track(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,13 @@ import {
|
||||
getLocatorIdFromNodeData
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
const TOOLTIP_VALUE_TYPES: Readonly<string[]> = [
|
||||
'asset',
|
||||
'combo',
|
||||
'number',
|
||||
'text'
|
||||
]
|
||||
|
||||
interface ProcessedWidget {
|
||||
advanced: boolean
|
||||
handleContextMenu: (e: PointerEvent) => void
|
||||
@@ -65,7 +72,7 @@ interface ProcessedWidget {
|
||||
}
|
||||
|
||||
interface WidgetUiCallbacks {
|
||||
getTooltipConfig: (widget: SafeWidgetData) => TooltipOptions
|
||||
getTooltipConfig: (widget: SafeWidgetData, fullVal?: string) => TooltipOptions
|
||||
handleNodeRightClick: (e: PointerEvent, nodeId: string) => void
|
||||
}
|
||||
|
||||
@@ -316,7 +323,11 @@ export function computeProcessedWidgets({
|
||||
executionErrorStore
|
||||
)
|
||||
|
||||
const tooltipConfig = ui.getTooltipConfig(widget)
|
||||
const valueTooltip =
|
||||
TOOLTIP_VALUE_TYPES.includes(widget.type) && String(value).length > 10
|
||||
? String(value)
|
||||
: undefined
|
||||
const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip)
|
||||
const handleContextMenu = (e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -372,7 +383,10 @@ export function useProcessedWidgets(
|
||||
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(nodeType)
|
||||
|
||||
const ui: WidgetUiCallbacks = {
|
||||
getTooltipConfig: (widget) => createTooltipConfig(getWidgetTooltip(widget)),
|
||||
getTooltipConfig: (widget, fullValue = '') =>
|
||||
createTooltipConfig(
|
||||
[getWidgetTooltip(widget), fullValue].join('\n\n').trim()
|
||||
),
|
||||
handleNodeRightClick
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'min-w-[4ch] flex-1 truncate pr-3 pl-1 text-left',
|
||||
'min-w-[4ch] flex-1 truncate pl-1 text-left',
|
||||
$slots.default && 'mr-5'
|
||||
)
|
||||
"
|
||||
@@ -42,12 +42,12 @@
|
||||
{{ selectedLabel || placeholder || '\u00a0' }}
|
||||
</span>
|
||||
<span
|
||||
class="flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg"
|
||||
class="flex h-full w-5 shrink-0 items-center justify-center rounded-r-lg"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-4 translate-x-1.5',
|
||||
'icon-[lucide--chevron-down]',
|
||||
disabled
|
||||
? 'bg-component-node-foreground-secondary'
|
||||
: 'bg-muted-foreground'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { mapKeys } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -359,12 +358,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
function restoreOutputs(
|
||||
outputs: Record<string, ExecutedWsMessage['output']>
|
||||
) {
|
||||
const parsedOutputs = mapKeys(
|
||||
outputs,
|
||||
(_, id) => executionIdToNodeLocatorId(app.rootGraph, id) ?? id
|
||||
)
|
||||
app.nodeOutputs = parsedOutputs
|
||||
nodeOutputs.value = { ...parsedOutputs }
|
||||
app.nodeOutputs = outputs
|
||||
nodeOutputs.value = { ...outputs }
|
||||
}
|
||||
|
||||
function updateNodeImages(node: LGraphNode) {
|
||||
|
||||