Compare commits
2 Commits
chore/upgr
...
feat/gradi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1583c15637 | ||
|
|
233f240503 |
2
.github/workflows/ci-lint-format.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
|
||||
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
{
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "T2IAdapterLoader",
|
||||
"pos": [100, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONTROL_NET",
|
||||
"type": "CONTROL_NET",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "T2IAdapterLoader"
|
||||
},
|
||||
"widgets_values": ["t2iadapter_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 300],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ResizeImagesByLongerEdge",
|
||||
"pos": [500, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ResizeImagesByLongerEdge"
|
||||
},
|
||||
"widgets_values": [1024]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "ImageScaleBy",
|
||||
"pos": [500, 280],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2, 3],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageScaleBy"
|
||||
},
|
||||
"widgets_values": ["lanczos", 1.5]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "ImageBatch",
|
||||
"pos": [900, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image1",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "image2",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [4],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageBatch"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SaveImage",
|
||||
"pos": [900, 300],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1250, 100],
|
||||
"size": [300, 250],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 3, 0, 4, 0, "IMAGE"],
|
||||
[2, 4, 0, 5, 0, "IMAGE"],
|
||||
[3, 4, 0, 6, 0, "IMAGE"],
|
||||
[4, 5, 0, 7, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
{
|
||||
"last_node_id": 5,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Load3DAnimation",
|
||||
"pos": [100, 100],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MESH",
|
||||
"type": "MESH",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Load3DAnimation"
|
||||
},
|
||||
"widgets_values": ["model.glb"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Preview3DAnimation",
|
||||
"pos": [450, 100],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "mesh",
|
||||
"type": "MESH",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Preview3DAnimation"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ConditioningAverage ",
|
||||
"pos": [100, 300],
|
||||
"size": [300, 100],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "conditioning_to",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "conditioning_from",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ConditioningAverage "
|
||||
},
|
||||
"widgets_values": [1]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "SDV_img2vid_Conditioning",
|
||||
"pos": [450, 300],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip_vision",
|
||||
"type": "CLIP_VISION",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "init_image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"links": [2],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SDV_img2vid_Conditioning"
|
||||
},
|
||||
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [800, 300],
|
||||
"size": [300, 262],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 3, 0, 5, 1, "CONDITIONING"],
|
||||
[2, 4, 2, 5, 3, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"id": "save-image-and-webm-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 100],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1, 2]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "SaveImage",
|
||||
"pos": [450, 100],
|
||||
"size": [210, 270],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "SaveWEBM",
|
||||
"pos": [450, 450],
|
||||
"size": [210, 368],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI", "vp9", 6, 32]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 10, 0, 11, 0, "IMAGE"],
|
||||
[2, 10, 0, 12, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.17.0",
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
@@ -215,14 +215,6 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
|
||||
})
|
||||
|
||||
test('Does not add duplicate filter with same type and value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await expectFilterChips(comfyPage, ['MODEL'])
|
||||
})
|
||||
|
||||
test('Can remove filter', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 99 KiB |
@@ -1,42 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Save Image and WEBM preview',
|
||||
{ tag: ['@screenshot', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('Can preview both SaveImage and SaveWEBM outputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/save_image_and_animated_webp'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
|
||||
|
||||
// Wait for SaveImage to render an img inside .image-preview
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
// Wait for SaveWEBM to render a video inside .video-preview
|
||||
await expect(saveWebmNode.locator('.video-preview video')).toBeVisible({
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'save-image-and-webm-preview.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 89 KiB |
@@ -1,55 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
async function loadImageOnNode(
|
||||
comfyPage: Awaited<
|
||||
ReturnType<(typeof test)['info']>
|
||||
>['fixtures']['comfyPage']
|
||||
) {
|
||||
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return imagePreview
|
||||
}
|
||||
|
||||
test('opens mask editor from image preview button', async ({ comfyPage }) => {
|
||||
const imagePreview = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.locator('[role="img"]').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows image context menu options', async ({ comfyPage }) => {
|
||||
await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
await expect(contextMenu.getByText('Open Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Save Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
|
||||
})
|
||||
})
|
||||
25
global.d.ts
vendored
@@ -10,28 +10,9 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
client_id: string | number | undefined
|
||||
session_id: string | number | undefined
|
||||
session_number: string | number | undefined
|
||||
}
|
||||
|
||||
interface GtagFunction {
|
||||
<TField extends GtagGetFieldName>(
|
||||
command: 'get',
|
||||
targetId: string,
|
||||
fieldName: TField,
|
||||
callback: (value: GtagGetFieldValueMap[TField]) => void
|
||||
): void
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
ga_measurement_id?: string
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
@@ -55,8 +36,12 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
__ga_identity__?: {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.40.7",
|
||||
"version": "1.40.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -10,7 +10,7 @@ catalog:
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@lobehub/i18n-cli': ^1.25.1
|
||||
'@nx/eslint': 22.2.6
|
||||
'@nx/playwright': 22.2.6
|
||||
'@nx/storybook': 22.2.4
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { Component } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import type {
|
||||
@@ -114,7 +113,6 @@ function createWrapper({
|
||||
SubgraphBreadcrumb: true,
|
||||
QueueProgressOverlay: true,
|
||||
QueueInlineProgressSummary: true,
|
||||
QueueNotificationBannerHost: true,
|
||||
CurrentUserButton: true,
|
||||
LoginButton: true,
|
||||
ContextMenu: {
|
||||
@@ -144,18 +142,6 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl(createJob(id, status))
|
||||
}
|
||||
|
||||
function createComfyActionbarStub(actionbarTarget: HTMLElement) {
|
||||
return defineComponent({
|
||||
name: 'ComfyActionbar',
|
||||
setup(_, { emit }) {
|
||||
onMounted(() => {
|
||||
emit('update:progressTarget', actionbarTarget)
|
||||
})
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TopMenuSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
@@ -215,17 +201,6 @@ describe('TopMenuSection', () => {
|
||||
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the active jobs indicator when no jobs are active', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides queue progress overlay when QPO V2 is enabled', async () => {
|
||||
@@ -341,7 +316,15 @@ describe('TopMenuSection', () => {
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
const ComfyActionbarStub = defineComponent({
|
||||
name: 'ComfyActionbar',
|
||||
setup(_, { emit }) {
|
||||
onMounted(() => {
|
||||
emit('update:progressTarget', actionbarTarget)
|
||||
})
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pinia,
|
||||
@@ -363,103 +346,6 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe(QueueNotificationBannerHost, () => {
|
||||
const configureSettings = (
|
||||
pinia: ReturnType<typeof createTestingPinia>,
|
||||
qpoV2Enabled: boolean
|
||||
) => {
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
it('renders queue notification banners when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('renders queue notification banners when QPO V2 is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, false)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('renders inline summary above banners when both are visible', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
const html = wrapper.html()
|
||||
const inlineSummaryIndex = html.indexOf(
|
||||
'queue-inline-progress-summary-stub'
|
||||
)
|
||||
const queueBannerIndex = html.indexOf(
|
||||
'queue-notification-banner-host-stub'
|
||||
)
|
||||
|
||||
expect(inlineSummaryIndex).toBeGreaterThan(-1)
|
||||
expect(queueBannerIndex).toBeGreaterThan(-1)
|
||||
expect(inlineSummaryIndex).toBeLessThan(queueBannerIndex)
|
||||
})
|
||||
|
||||
it('does not teleport queue notification banners when actionbar is floating', async () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
const actionbarTarget = document.createElement('div')
|
||||
document.body.appendChild(actionbarTarget)
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pinia,
|
||||
attachTo: document.body,
|
||||
stubs: {
|
||||
ComfyActionbar: ComfyActionbarStub,
|
||||
QueueNotificationBannerHost: true
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
actionbarTarget.querySelector('queue-notification-banner-host-stub')
|
||||
).toBeNull()
|
||||
expect(
|
||||
wrapper
|
||||
.findComponent({ name: 'QueueNotificationBannerHost' })
|
||||
.exists()
|
||||
).toBe(true)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
actionbarTarget.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
|
||||
@@ -36,14 +36,7 @@
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
@@ -67,7 +60,7 @@
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="relative px-3"
|
||||
class="px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
@@ -75,12 +68,6 @@
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="activeJobsCount > 0"
|
||||
data-testid="active-jobs-indicator"
|
||||
variant="dot"
|
||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
@@ -118,7 +105,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div>
|
||||
<Teleport
|
||||
v-if="inlineProgressSummaryTarget"
|
||||
:to="inlineProgressSummaryTarget"
|
||||
@@ -134,10 +121,6 @@
|
||||
class="pr-1"
|
||||
:hidden="isQueueOverlayExpanded"
|
||||
/>
|
||||
<QueueNotificationBannerHost
|
||||
v-if="shouldShowQueueNotificationBanners"
|
||||
class="pr-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -152,9 +135,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
@@ -175,7 +156,6 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -224,9 +204,6 @@ const isQueueProgressOverlayEnabled = computed(
|
||||
const shouldShowInlineProgressSummary = computed(
|
||||
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
||||
)
|
||||
const shouldShowQueueNotificationBanners = computed(
|
||||
() => isActionbarEnabled.value
|
||||
)
|
||||
const progressTarget = ref<HTMLElement | null>(null)
|
||||
function updateProgressTarget(target: HTMLElement | null) {
|
||||
progressTarget.value = target
|
||||
@@ -260,8 +237,6 @@ const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
@@ -3,26 +3,49 @@
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
|
||||
<input
|
||||
v-model.number="x"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
|
||||
<input
|
||||
v-model.number="y"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
|
||||
<input
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
|
||||
<input
|
||||
v-model.number="height"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
|
||||
70
src/components/colorcorrect/GradientSlider.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import GradientSlider from './GradientSlider.vue'
|
||||
import type { ColorStop } from './gradients'
|
||||
import { interpolateStops } from './gradients'
|
||||
|
||||
const TEST_STOPS: ColorStop[] = [
|
||||
[0, 0, 0, 0],
|
||||
[1, 255, 255, 255]
|
||||
]
|
||||
|
||||
function mountSlider(props: {
|
||||
stops?: ColorStop[]
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}) {
|
||||
return mount(GradientSlider, {
|
||||
props: { stops: TEST_STOPS, ...props }
|
||||
})
|
||||
}
|
||||
|
||||
describe('GradientSlider', () => {
|
||||
it('passes min, max, step to SliderRoot', () => {
|
||||
const wrapper = mountSlider({
|
||||
modelValue: 50,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 5
|
||||
})
|
||||
const thumb = wrapper.find('[role="slider"]')
|
||||
expect(thumb.attributes('aria-valuemin')).toBe('-100')
|
||||
expect(thumb.attributes('aria-valuemax')).toBe('100')
|
||||
})
|
||||
|
||||
it('renders slider root with track and thumb', () => {
|
||||
const wrapper = mountSlider({ modelValue: 0 })
|
||||
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render SliderRange', () => {
|
||||
const wrapper = mountSlider({ modelValue: 50 })
|
||||
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interpolateStops', () => {
|
||||
it('returns start color at t=0', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 0)).toBe('rgb(0,0,0)')
|
||||
})
|
||||
|
||||
it('returns end color at t=1', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 1)).toBe('rgb(255,255,255)')
|
||||
})
|
||||
|
||||
it('returns midpoint color at t=0.5', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 0.5)).toBe('rgb(128,128,128)')
|
||||
})
|
||||
|
||||
it('clamps values below 0', () => {
|
||||
expect(interpolateStops(TEST_STOPS, -1)).toBe('rgb(0,0,0)')
|
||||
})
|
||||
|
||||
it('clamps values above 1', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 2)).toBe('rgb(255,255,255)')
|
||||
})
|
||||
})
|
||||
87
src/components/colorcorrect/GradientSlider.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { SliderRoot, SliderThumb, SliderTrack } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ColorStop } from '@/components/colorcorrect/gradients'
|
||||
import {
|
||||
interpolateStops,
|
||||
stopsToGradient
|
||||
} from '@/components/colorcorrect/gradients'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
stops,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
stops: ColorStop[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const sliderValue = computed({
|
||||
get: () => [modelValue.value],
|
||||
set: (v: number[]) => {
|
||||
if (v.length) modelValue.value = v[0]
|
||||
}
|
||||
})
|
||||
|
||||
const gradient = computed(() => stopsToGradient(stops))
|
||||
|
||||
const thumbColor = computed(() => {
|
||||
const t = max === min ? 0 : (modelValue.value - min) / (max - min)
|
||||
return interpolateStops(stops, t)
|
||||
})
|
||||
|
||||
const pressed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SliderRoot
|
||||
v-model="sliderValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full touch-none items-center select-none',
|
||||
'data-[disabled]:opacity-50'
|
||||
)
|
||||
"
|
||||
:style="{ '--reka-slider-thumb-transform': 'translate(-50%, -50%)' }"
|
||||
@slide-start="pressed = true"
|
||||
@slide-move="pressed = true"
|
||||
@slide-end="pressed = false"
|
||||
>
|
||||
<SliderTrack
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2.5 w-full grow cursor-pointer overflow-visible rounded-full',
|
||||
'before:absolute before:-inset-2 before:block before:bg-transparent'
|
||||
)
|
||||
"
|
||||
:style="{ background: gradient }"
|
||||
>
|
||||
<SliderThumb
|
||||
:class="
|
||||
cn(
|
||||
'block size-4 shrink-0 cursor-grab rounded-full shadow-md ring-1 ring-black/25',
|
||||
'transition-[color,box-shadow,background-color]',
|
||||
'before:absolute before:-inset-1.5 before:block before:rounded-full before:bg-transparent',
|
||||
'hover:ring-2 hover:ring-black/40 focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-hidden',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed }
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: thumbColor, top: '50%' }"
|
||||
/>
|
||||
</SliderTrack>
|
||||
</SliderRoot>
|
||||
</template>
|
||||
37
src/components/colorcorrect/gradients.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type ColorStop = readonly [
|
||||
offset: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
]
|
||||
|
||||
export function stopsToGradient(stops: ColorStop[]): string {
|
||||
const colors = stops.map(
|
||||
([offset, r, g, b]) => `rgb(${r},${g},${b}) ${offset * 100}%`
|
||||
)
|
||||
return `linear-gradient(to right, ${colors.join(', ')})`
|
||||
}
|
||||
|
||||
export function interpolateStops(stops: ColorStop[], t: number): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
|
||||
if (clamped <= stops[0][0]) {
|
||||
const [, r, g, b] = stops[0]
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const [o1, r1, g1, b1] = stops[i]
|
||||
const [o2, r2, g2, b2] = stops[i + 1]
|
||||
if (clamped >= o1 && clamped <= o2) {
|
||||
const f = o2 === o1 ? 0 : (clamped - o1) / (o2 - o1)
|
||||
const r = Math.round(r1 + (r2 - r1) * f)
|
||||
const g = Math.round(g1 + (g2 - g1) * f)
|
||||
const b = Math.round(b1 + (b2 - b1) * f)
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
}
|
||||
|
||||
const [, r, g, b] = stops[stops.length - 1]
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
</Button>
|
||||
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
|
||||
<input
|
||||
ref="inputField"
|
||||
v-bind="inputAttrs"
|
||||
:value="displayValue ?? modelValue"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
|
||||
)
|
||||
"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
displayValue,
|
||||
parseValue
|
||||
} = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
hideButtons?: boolean
|
||||
displayValue?: string
|
||||
parseValue?: (raw: string) => number | undefined
|
||||
inputAttrs?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
if (textEdit.value) textEdit.value = false
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const raw = target.value.trim()
|
||||
const parsed = parseValue
|
||||
? parseValue(raw)
|
||||
: raw === ''
|
||||
? undefined
|
||||
: Number(raw)
|
||||
if (parsed != null && !isNaN(parsed)) {
|
||||
modelValue.value = clamp(parsed)
|
||||
} else {
|
||||
target.value = displayValue ?? String(modelValue.value)
|
||||
}
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
}
|
||||
</script>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
|
||||
:class="isCloud ? 'border-b' : ''"
|
||||
class="flex w-[490px] flex-col border-t-1 border-border-default"
|
||||
:class="isCloud ? 'border-b-1' : ''"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-5 text-muted-foreground">
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.description')
|
||||
@@ -14,210 +14,32 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
|
||||
|
||||
<!-- QUICK FIX AVAILABLE Section -->
|
||||
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
|
||||
<!-- Section header with Replace button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase text-primary">
|
||||
{{ $t('nodeReplacement.quickFixAvailable') }}
|
||||
</span>
|
||||
<div class="h-2 w-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
|
||||
variant="primary"
|
||||
size="md"
|
||||
:disabled="selectedTypes.size === 0"
|
||||
@click="handleReplaceSelected"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
|
||||
{{
|
||||
$t('nodeReplacement.replaceSelected', {
|
||||
count: selectedTypes.size
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Replaceable nodes list -->
|
||||
<div
|
||||
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<!-- Select All row (sticky header) -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
|
||||
pendingNodes.length > 0
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'opacity-50 pointer-events-none'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="
|
||||
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
|
||||
"
|
||||
@click="toggleSelectAll"
|
||||
@keydown.enter.prevent="toggleSelectAll"
|
||||
@keydown.space.prevent="toggleSelectAll"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
isAllSelected || isSomeSelected
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="isAllSelected"
|
||||
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else-if="isSomeSelected"
|
||||
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs font-medium uppercase text-muted-foreground">
|
||||
{{ $t('nodeReplacement.compatibleAlternatives') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Replaceable node items -->
|
||||
<div
|
||||
v-for="node in replaceableNodes"
|
||||
:key="node.label"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2',
|
||||
replacedTypes.has(node.label)
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
? 'true'
|
||||
: 'false'
|
||||
"
|
||||
@click="toggleNode(node.label)"
|
||||
@keydown.enter.prevent="toggleNode(node.label)"
|
||||
@keydown.space.prevent="toggleNode(node.label)"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="
|
||||
replacedTypes.has(node.label) || selectedTypes.has(node.label)
|
||||
"
|
||||
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="replacedTypes.has(node.label)"
|
||||
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaced') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaceable') }}
|
||||
</span>
|
||||
<span class="text-sm text-foreground">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MANUAL INSTALLATION REQUIRED Section -->
|
||||
<!-- Missing Nodes List Wrapper -->
|
||||
<div
|
||||
v-if="nonReplaceableNodes.length > 0"
|
||||
class="flex max-h-[200px] flex-col gap-2"
|
||||
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
||||
>
|
||||
<!-- Section header -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase text-error">
|
||||
{{ $t('nodeReplacement.installationRequired') }}
|
||||
<div
|
||||
v-for="(node, i) in uniqueNodes"
|
||||
:key="i"
|
||||
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
|
||||
>
|
||||
<span class="text-xs">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
<i class="icon-[lucide--info] text-xs text-error" />
|
||||
</div>
|
||||
|
||||
<!-- Non-replaceable nodes list -->
|
||||
<div
|
||||
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<div
|
||||
v-for="node in nonReplaceableNodes"
|
||||
:key="node.label"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
|
||||
>
|
||||
{{ $t('nodeReplacement.notReplaceable') }}
|
||||
</span>
|
||||
<span class="text-sm text-foreground">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="node.hint" class="text-xs text-muted-foreground">
|
||||
{{ node.hint }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="node.action"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
@click="node.action.callback"
|
||||
>
|
||||
{{ node.action.text }}
|
||||
</Button>
|
||||
</div>
|
||||
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom instruction box -->
|
||||
<div
|
||||
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<p class="m-0 text-xs leading-5 text-neutral-foreground">
|
||||
<i18n-t keypath="nodeReplacement.instructionMessage">
|
||||
<template #red>
|
||||
<span class="text-error">{{
|
||||
$t('nodeReplacement.redHighlight')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<!-- Bottom instruction -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.replacementInstruction')
|
||||
: $t('missingNodes.oss.replacementInstruction')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,39 +47,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
// Get missing core nodes for OSS mode
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
interface ProcessedNode {
|
||||
label: string
|
||||
hint?: string
|
||||
action?: { text: string; callback: () => void }
|
||||
isReplaceable: boolean
|
||||
replacement?: NodeReplacement
|
||||
}
|
||||
|
||||
const replacedTypes = ref<Set<string>>(new Set())
|
||||
|
||||
const uniqueNodes = computed<ProcessedNode[]>(() => {
|
||||
const seenTypes = new Set<string>()
|
||||
return missingNodeTypes
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
if (seenTypes.has(type)) return false
|
||||
@@ -269,81 +75,10 @@ const uniqueNodes = computed<ProcessedNode[]>(() => {
|
||||
return {
|
||||
label: node.type,
|
||||
hint: node.hint,
|
||||
action: node.action,
|
||||
isReplaceable: node.isReplaceable ?? false,
|
||||
replacement: node.replacement
|
||||
action: node.action
|
||||
}
|
||||
}
|
||||
return { label: node, isReplaceable: false }
|
||||
return { label: node }
|
||||
})
|
||||
})
|
||||
|
||||
const replaceableNodes = computed(() =>
|
||||
uniqueNodes.value.filter((n) => n.isReplaceable)
|
||||
)
|
||||
|
||||
const pendingNodes = computed(() =>
|
||||
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
|
||||
)
|
||||
|
||||
const nonReplaceableNodes = computed(() =>
|
||||
uniqueNodes.value.filter((n) => !n.isReplaceable)
|
||||
)
|
||||
|
||||
// Selection state - all pending nodes selected by default
|
||||
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
|
||||
|
||||
const isAllSelected = computed(
|
||||
() =>
|
||||
pendingNodes.value.length > 0 &&
|
||||
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
|
||||
)
|
||||
|
||||
const isSomeSelected = computed(
|
||||
() => selectedTypes.value.size > 0 && !isAllSelected.value
|
||||
)
|
||||
|
||||
function toggleNode(label: string) {
|
||||
if (replacedTypes.value.has(label)) return
|
||||
const next = new Set(selectedTypes.value)
|
||||
if (next.has(label)) {
|
||||
next.delete(label)
|
||||
} else {
|
||||
next.add(label)
|
||||
}
|
||||
selectedTypes.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedTypes.value = new Set()
|
||||
} else {
|
||||
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceSelected() {
|
||||
const selected = missingNodeTypes.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
return selectedTypes.value.has(type)
|
||||
})
|
||||
|
||||
const result = replaceNodesInPlace(selected)
|
||||
const nextReplaced = new Set(replacedTypes.value)
|
||||
const nextSelected = new Set(selectedTypes.value)
|
||||
for (const type of result) {
|
||||
nextReplaced.add(type)
|
||||
nextSelected.delete(type)
|
||||
}
|
||||
replacedTypes.value = nextReplaced
|
||||
selectedTypes.value = nextSelected
|
||||
|
||||
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
|
||||
const allReplaced = replaceableNodes.value.every((n) =>
|
||||
nextReplaced.has(n.label)
|
||||
)
|
||||
if (allReplaced && nonReplaceableNodes.value.length === 0) {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,18 +30,8 @@
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
|
||||
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">
|
||||
{{ $t('nodeReplacement.skipForNow') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div
|
||||
v-else-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2"
|
||||
>
|
||||
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@@ -58,9 +48,9 @@
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Manager buttons -->
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
||||
<Button variant="textonly" @click="handleOpenManager">{{
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
@@ -92,17 +82,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const { missingNodeTypes } = defineProps<{
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -124,12 +109,6 @@ function openShowMissingNodesSetting() {
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
function handleOpenManager() {
|
||||
managerState.openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
|
||||
// Check if any of the missing packs are currently being installed
|
||||
const isInstalling = computed(() => {
|
||||
@@ -149,29 +128,15 @@ const showInstallAllButton = computed(() => {
|
||||
return managerState.shouldShowInstallButton.value
|
||||
})
|
||||
|
||||
const hasNonReplaceableNodes = computed(
|
||||
() =>
|
||||
missingNodeTypes?.some(
|
||||
(n) =>
|
||||
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
|
||||
) ?? false
|
||||
)
|
||||
const openManager = async () => {
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
|
||||
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
|
||||
const hadMissingPacks = ref(false)
|
||||
|
||||
watch(
|
||||
missingNodePacks,
|
||||
(packs) => {
|
||||
if (packs && packs.length > 0) hadMissingPacks.value = true
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Only consider "all installed" when packs transitioned from non-empty to empty
|
||||
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
|
||||
// Computed to check if all missing nodes have been installed
|
||||
const allMissingNodesInstalled = computed(() => {
|
||||
if (!hadMissingPacks.value) return false
|
||||
return (
|
||||
!isLoading.value &&
|
||||
!isInstalling.value &&
|
||||
|
||||
@@ -162,7 +162,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -120,12 +120,12 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -60,9 +60,6 @@
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
@pointerdown.capture="forwardPanEvent"
|
||||
@pointerup.capture="forwardPanEvent"
|
||||
@pointermove.capture="forwardPanEvent"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<LGraphNode
|
||||
@@ -117,7 +114,6 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -164,7 +160,6 @@ import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
@@ -545,13 +540,4 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
if (
|
||||
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
|
||||
!isMiddlePointerInput(e)
|
||||
)
|
||||
return
|
||||
|
||||
canvasInteractions.forwardEventToCanvas(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -67,6 +67,18 @@ describe('HoneyToast', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies collapsed max-height class when collapsed', async () => {
|
||||
const wrapper = mountComponent({ visible: true, expanded: false })
|
||||
await nextTick()
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('has aria-live="polite" for accessibility', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
@@ -115,6 +127,11 @@ describe('HoneyToast', () => {
|
||||
expect(content?.textContent).toBe('expanded')
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,13 +26,13 @@ function toggle() {
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
|
||||
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
|
||||
'overflow-hidden transition-all duration-300',
|
||||
isExpanded ? 'max-h-[400px]' : 'max-h-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
:src="imageUrl"
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none"
|
||||
class="block size-full object-contain select-none brightness-50"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
@@ -36,12 +36,14 @@
|
||||
|
||||
<div
|
||||
v-if="imageUrl && !isLoading"
|
||||
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
|
||||
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
/>
|
||||
>
|
||||
<div class="pointer-events-none size-full" :style="cropImageStyle" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="handle in resizeHandles"
|
||||
@@ -129,6 +131,7 @@ const {
|
||||
isLockEnabled,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
|
||||
73
src/components/queue/CompletionSummaryBanner.stories.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
|
||||
|
||||
const meta: Meta<typeof CompletionSummaryBanner> = {
|
||||
title: 'Queue/CompletionSummaryBanner',
|
||||
component: CompletionSummaryBanner,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const thumb = (hex: string) =>
|
||||
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23${hex}'/></svg>`
|
||||
|
||||
const thumbs = [thumb('ff6b6b'), thumb('4dabf7'), thumb('51cf66')]
|
||||
|
||||
export const AllSuccessSingle: Story = {
|
||||
args: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: [thumbs[0]]
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSuccessPlural: Story = {
|
||||
args: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 3,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: thumbs
|
||||
}
|
||||
}
|
||||
|
||||
export const MixedSingleSingle: Story = {
|
||||
args: {
|
||||
mode: 'mixed',
|
||||
completedCount: 1,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: thumbs.slice(0, 2)
|
||||
}
|
||||
}
|
||||
|
||||
export const MixedPluralPlural: Story = {
|
||||
args: {
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 3,
|
||||
thumbnailUrls: thumbs
|
||||
}
|
||||
}
|
||||
|
||||
export const AllFailedSingle: Story = {
|
||||
args: {
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: []
|
||||
}
|
||||
}
|
||||
|
||||
export const AllFailedPlural: Story = {
|
||||
args: {
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 4,
|
||||
thumbnailUrls: []
|
||||
}
|
||||
}
|
||||
91
src/components/queue/CompletionSummaryBanner.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
jobsCompleted: '{count} job completed | {count} jobs completed',
|
||||
jobsFailed: '{count} job failed | {count} jobs failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountComponent = (props: Record<string, unknown>) =>
|
||||
mount(CompletionSummaryBanner, {
|
||||
props: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe('CompletionSummaryBanner', () => {
|
||||
it('renders success mode text, thumbnails, and aria label', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 3,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: [
|
||||
'https://example.com/thumb-a.png',
|
||||
'https://example.com/thumb-b.png'
|
||||
],
|
||||
ariaLabel: 'Open queue summary'
|
||||
})
|
||||
|
||||
const button = wrapper.get('button')
|
||||
expect(button.attributes('aria-label')).toBe('Open queue summary')
|
||||
expect(wrapper.text()).toContain('3 jobs completed')
|
||||
|
||||
const thumbnailImages = wrapper.findAll('img')
|
||||
expect(thumbnailImages).toHaveLength(2)
|
||||
expect(thumbnailImages[0].attributes('src')).toBe(
|
||||
'https://example.com/thumb-a.png'
|
||||
)
|
||||
expect(thumbnailImages[1].attributes('src')).toBe(
|
||||
'https://example.com/thumb-b.png'
|
||||
)
|
||||
|
||||
const thumbnailContainers = wrapper.findAll('.inline-block.h-6.w-6')
|
||||
expect(thumbnailContainers[1].attributes('style')).toContain(
|
||||
'margin-left: -12px'
|
||||
)
|
||||
|
||||
expect(wrapper.html()).not.toContain('icon-[lucide--circle-alert]')
|
||||
})
|
||||
|
||||
it('renders mixed mode with success and failure counts', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1
|
||||
})
|
||||
|
||||
const summaryText = wrapper.text().replace(/\s+/g, ' ').trim()
|
||||
expect(summaryText).toContain('2 jobs completed, 1 job failed')
|
||||
})
|
||||
|
||||
it('renders failure mode icon without thumbnails', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 4
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('4 jobs failed')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
109
src/components/queue/CompletionSummaryBanner.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="group w-full justify-between gap-3 p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="props.ariaLabel"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
|
||||
<i
|
||||
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none text-destructive-background"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
v-if="props.mode !== 'allFailed'"
|
||||
class="relative inline-flex h-6 items-center"
|
||||
>
|
||||
<span
|
||||
v-for="(url, idx) in props.thumbnailUrls"
|
||||
:key="url + idx"
|
||||
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
|
||||
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
|
||||
>
|
||||
<img
|
||||
:src="url"
|
||||
:alt="$t('sideToolbar.queueProgressOverlay.preview')"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-[14px] font-normal text-text-primary">
|
||||
<template v-if="props.mode === 'allSuccess'">
|
||||
<i18n-t
|
||||
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
|
||||
:plural="props.completedCount"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ props.completedCount }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template v-else-if="props.mode === 'mixed'">
|
||||
<i18n-t
|
||||
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
|
||||
:plural="props.completedCount"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ props.completedCount }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<span>, </span>
|
||||
<i18n-t
|
||||
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
|
||||
:plural="props.failedCount"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ props.failedCount }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i18n-t
|
||||
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
|
||||
:plural="props.failedCount"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ props.failedCount }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="flex items-center justify-center rounded p-1 text-text-secondary transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
CompletionSummary,
|
||||
CompletionSummaryMode
|
||||
} from '@/composables/queue/useCompletionSummary'
|
||||
|
||||
type Props = {
|
||||
mode: CompletionSummaryMode
|
||||
completedCount: CompletionSummary['completedCount']
|
||||
failedCount: CompletionSummary['failedCount']
|
||||
thumbnailUrls?: CompletionSummary['thumbnailUrls']
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
thumbnailUrls: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,140 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners'
|
||||
|
||||
import QueueNotificationBanner from './QueueNotificationBanner.vue'
|
||||
|
||||
const meta: Meta<typeof QueueNotificationBanner> = {
|
||||
title: 'Queue/QueueNotificationBanner',
|
||||
component: QueueNotificationBanner,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const thumbnail = (hex: string) =>
|
||||
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
|
||||
|
||||
const args = (notification: QueueNotificationBannerItem) => ({ notification })
|
||||
|
||||
export const Queueing: Story = {
|
||||
args: args({
|
||||
type: 'queuedPending',
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
|
||||
export const QueueingMultiple: Story = {
|
||||
args: args({
|
||||
type: 'queuedPending',
|
||||
count: 3
|
||||
})
|
||||
}
|
||||
|
||||
export const Queued: Story = {
|
||||
args: args({
|
||||
type: 'queued',
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
|
||||
export const QueuedMultiple: Story = {
|
||||
args: args({
|
||||
type: 'queued',
|
||||
count: 4
|
||||
})
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
args: args({
|
||||
type: 'completed',
|
||||
count: 1,
|
||||
thumbnailUrls: [thumbnail('4dabf7')]
|
||||
})
|
||||
}
|
||||
|
||||
export const CompletedMultiple: Story = {
|
||||
args: args({
|
||||
type: 'completed',
|
||||
count: 4
|
||||
})
|
||||
}
|
||||
|
||||
export const CompletedMultipleWithThumbnail: Story = {
|
||||
args: args({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
thumbnail('ff6b6b'),
|
||||
thumbnail('4dabf7'),
|
||||
thumbnail('51cf66')
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: args({
|
||||
type: 'failed',
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
|
||||
export const Gallery: Story = {
|
||||
render: () => ({
|
||||
components: { QueueNotificationBanner },
|
||||
setup() {
|
||||
const queueing = args({
|
||||
type: 'queuedPending',
|
||||
count: 1
|
||||
})
|
||||
const queued = args({
|
||||
type: 'queued',
|
||||
count: 2
|
||||
})
|
||||
const completed = args({
|
||||
type: 'completed',
|
||||
count: 1,
|
||||
thumbnailUrls: [thumbnail('ff6b6b')]
|
||||
})
|
||||
const completedMultiple = args({
|
||||
type: 'completed',
|
||||
count: 4
|
||||
})
|
||||
const completedMultipleWithThumbnail = args({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
thumbnail('51cf66'),
|
||||
thumbnail('ffd43b'),
|
||||
thumbnail('ff922b')
|
||||
]
|
||||
})
|
||||
const failed = args({
|
||||
type: 'failed',
|
||||
count: 2
|
||||
})
|
||||
|
||||
return {
|
||||
queueing,
|
||||
queued,
|
||||
completed,
|
||||
completedMultiple,
|
||||
completedMultipleWithThumbnail,
|
||||
failed
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2">
|
||||
<QueueNotificationBanner v-bind="queueing" />
|
||||
<QueueNotificationBanner v-bind="queued" />
|
||||
<QueueNotificationBanner v-bind="completed" />
|
||||
<QueueNotificationBanner v-bind="completedMultiple" />
|
||||
<QueueNotificationBanner v-bind="completedMultipleWithThumbnail" />
|
||||
<QueueNotificationBanner v-bind="failed" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue'
|
||||
import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
queue: {
|
||||
jobAddedToQueue: 'Job added to queue',
|
||||
jobQueueing: 'Job queueing'
|
||||
},
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
preview: 'Preview',
|
||||
jobCompleted: 'Job completed',
|
||||
jobFailed: 'Job failed',
|
||||
jobsAddedToQueue:
|
||||
'{count} job added to queue | {count} jobs added to queue',
|
||||
jobsCompleted: '{count} job completed | {count} jobs completed',
|
||||
jobsFailed: '{count} job failed | {count} jobs failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountComponent = (notification: QueueNotificationBannerItem) =>
|
||||
mount(QueueNotificationBanner, {
|
||||
props: { notification },
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe(QueueNotificationBanner, () => {
|
||||
it('renders singular queued message without count prefix', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'queued',
|
||||
count: 1
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Job added to queue')
|
||||
expect(wrapper.text()).not.toContain('1 job')
|
||||
})
|
||||
|
||||
it('renders queued message with pluralization', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'queued',
|
||||
count: 2
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('2 jobs added to queue')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--check]')
|
||||
})
|
||||
|
||||
it('renders queued pending message with spinner icon', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'queuedPending',
|
||||
count: 1
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Job queueing')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--loader-circle]')
|
||||
expect(wrapper.html()).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('renders failed message and alert icon', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'failed',
|
||||
count: 1
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Job failed')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
|
||||
})
|
||||
|
||||
it('renders completed message with thumbnail preview when provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'completed',
|
||||
count: 3,
|
||||
thumbnailUrls: ['https://example.com/preview.png']
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('3 jobs completed')
|
||||
const image = wrapper.get('img')
|
||||
expect(image.attributes('src')).toBe('https://example.com/preview.png')
|
||||
expect(image.attributes('alt')).toBe('Preview')
|
||||
})
|
||||
|
||||
it('renders two completion thumbnail previews', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
'https://example.com/preview-1.png',
|
||||
'https://example.com/preview-2.png'
|
||||
]
|
||||
})
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe(
|
||||
'https://example.com/preview-1.png'
|
||||
)
|
||||
expect(images[1].attributes('src')).toBe(
|
||||
'https://example.com/preview-2.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('caps completion thumbnail previews at two', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
'https://example.com/preview-1.png',
|
||||
'https://example.com/preview-2.png',
|
||||
'https://example.com/preview-3.png',
|
||||
'https://example.com/preview-4.png'
|
||||
]
|
||||
})
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe(
|
||||
'https://example.com/preview-1.png'
|
||||
)
|
||||
expect(images[1].attributes('src')).toBe(
|
||||
'https://example.com/preview-2.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<div class="inline-flex overflow-hidden rounded-lg bg-secondary-background">
|
||||
<div class="flex items-center gap-2 p-1 pr-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative shrink-0 items-center rounded-[4px]',
|
||||
showsCompletionPreview && showThumbnails
|
||||
? 'flex h-8 overflow-visible p-0'
|
||||
: showsCompletionPreview
|
||||
? 'flex size-8 justify-center overflow-hidden p-0'
|
||||
: 'flex size-8 justify-center p-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="showThumbnails">
|
||||
<div class="flex h-8 shrink-0 items-center">
|
||||
<div
|
||||
v-for="(thumbnailUrl, index) in thumbnailUrls"
|
||||
:key="`completion-preview-${index}`"
|
||||
:class="
|
||||
cn(
|
||||
'relative size-8 shrink-0 overflow-hidden rounded-[4px]',
|
||||
index > 0 && '-ml-3 ring-2 ring-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
:src="thumbnailUrl"
|
||||
:alt="t('sideToolbar.queueProgressOverlay.preview')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="showCompletionGradientFallback"
|
||||
class="size-full bg-linear-to-br from-coral-500 via-coral-500 to-azure-600"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', iconColorClass)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex h-full items-center">
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis text-center font-inter text-[12px] leading-normal font-normal text-base-foreground"
|
||||
>
|
||||
{{ bannerText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { QueueNotificationBanner } from '@/composables/queue/useQueueNotificationBanners'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { notification } = defineProps<{
|
||||
notification: QueueNotificationBanner
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const thumbnailUrls = computed(() => {
|
||||
if (notification.type !== 'completed') {
|
||||
return []
|
||||
}
|
||||
return notification.thumbnailUrls?.slice(0, 2) ?? []
|
||||
})
|
||||
|
||||
const showThumbnails = computed(() => {
|
||||
if (notification.type !== 'completed') {
|
||||
return false
|
||||
}
|
||||
return thumbnailUrls.value.length > 0
|
||||
})
|
||||
|
||||
const showCompletionGradientFallback = computed(
|
||||
() => notification.type === 'completed' && !showThumbnails.value
|
||||
)
|
||||
|
||||
const showsCompletionPreview = computed(
|
||||
() => showThumbnails.value || showCompletionGradientFallback.value
|
||||
)
|
||||
|
||||
const bannerText = computed(() => {
|
||||
const count = notification.count
|
||||
if (notification.type === 'queuedPending') {
|
||||
return t('queue.jobQueueing')
|
||||
}
|
||||
if (notification.type === 'queued') {
|
||||
if (count === 1) {
|
||||
return t('queue.jobAddedToQueue')
|
||||
}
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.jobsAddedToQueue',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
if (count === 1) {
|
||||
return t('sideToolbar.queueProgressOverlay.jobFailed')
|
||||
}
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.jobsFailed',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
}
|
||||
if (count === 1) {
|
||||
return t('sideToolbar.queueProgressOverlay.jobCompleted')
|
||||
}
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.jobsCompleted',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (notification.type === 'queuedPending') {
|
||||
return 'icon-[lucide--loader-circle]'
|
||||
}
|
||||
if (notification.type === 'queued') {
|
||||
return 'icon-[lucide--check]'
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
return 'icon-[lucide--circle-alert]'
|
||||
}
|
||||
return 'icon-[lucide--image]'
|
||||
})
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
if (notification.type === 'queuedPending') {
|
||||
return 'animate-spin text-slate-100'
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
return 'text-danger-200'
|
||||
}
|
||||
return 'text-slate-100'
|
||||
})
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="currentNotification"
|
||||
class="flex justify-end"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<QueueNotificationBanner :notification="currentNotification" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue'
|
||||
import { useQueueNotificationBanners } from '@/composables/queue/useQueueNotificationBanners'
|
||||
|
||||
const { currentNotification } = useQueueNotificationBanners()
|
||||
</script>
|
||||
69
src/components/queue/QueueOverlayEmpty.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
|
||||
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
expandCollapsedQueue: 'Expand job queue',
|
||||
noActiveJobs: 'No active jobs'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CompletionSummaryBannerStub = {
|
||||
name: 'CompletionSummaryBanner',
|
||||
props: [
|
||||
'mode',
|
||||
'completedCount',
|
||||
'failedCount',
|
||||
'thumbnailUrls',
|
||||
'ariaLabel'
|
||||
],
|
||||
emits: ['click'],
|
||||
template: '<button class="summary-banner" @click="$emit(\'click\')"></button>'
|
||||
}
|
||||
|
||||
const mountComponent = (summary: CompletionSummary) =>
|
||||
mount(QueueOverlayEmpty, {
|
||||
props: { summary },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
components: { CompletionSummaryBanner: CompletionSummaryBannerStub }
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayEmpty', () => {
|
||||
it('renders completion summary banner and proxies click', async () => {
|
||||
const summary: CompletionSummary = {
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['thumb-a']
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(summary)
|
||||
const summaryBanner = wrapper.findComponent(CompletionSummaryBannerStub)
|
||||
|
||||
expect(summaryBanner.exists()).toBe(true)
|
||||
expect(summaryBanner.props()).toMatchObject({
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['thumb-a'],
|
||||
ariaLabel: 'Expand job queue'
|
||||
})
|
||||
|
||||
await summaryBanner.trigger('click')
|
||||
expect(wrapper.emitted('summaryClick')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
27
src/components/queue/QueueOverlayEmpty.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="pointer-events-auto">
|
||||
<CompletionSummaryBanner
|
||||
:mode="summary.mode"
|
||||
:completed-count="summary.completedCount"
|
||||
:failed-count="summary.failedCount"
|
||||
:thumbnail-urls="summary.thumbnailUrls"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')"
|
||||
@click="$emit('summaryClick')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue'
|
||||
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
|
||||
defineProps<{ summary: CompletionSummary }>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'summaryClick'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -4,17 +4,46 @@
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:queued-count="queuedCount"
|
||||
@clear-history="$emit('clearHistory')"
|
||||
@clear-queued="$emit('clearQueued')"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3">
|
||||
<Button
|
||||
class="grow gap-1 justify-center"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
|
||||
</Button>
|
||||
<div class="ml-4 inline-flex items-center">
|
||||
<div
|
||||
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
|
||||
>
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
class="ml-2"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobFiltersBar
|
||||
:selected-job-tab="selectedJobTab"
|
||||
:selected-workflow-filter="selectedWorkflowFilter"
|
||||
:selected-sort-mode="selectedSortMode"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@show-assets="$emit('showAssets')"
|
||||
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
|
||||
@update:selected-workflow-filter="
|
||||
$emit('update:selectedWorkflowFilter', $event)
|
||||
@@ -42,7 +71,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
@@ -81,6 +112,8 @@ const emit = defineEmits<{
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ const i18n = createI18n({
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
running: 'running',
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history'
|
||||
}
|
||||
@@ -56,7 +54,6 @@ const mountHeader = (props = {}) =>
|
||||
headerTitle: 'Job queue',
|
||||
showConcurrentIndicator: true,
|
||||
concurrentWorkflowCount: 2,
|
||||
queuedCount: 3,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
@@ -83,25 +80,6 @@ describe('QueueOverlayHeader', () => {
|
||||
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows queued summary and emits clear queued', async () => {
|
||||
const wrapper = mountHeader({ queuedCount: 4 })
|
||||
|
||||
expect(wrapper.text()).toContain('4')
|
||||
expect(wrapper.text()).toContain('queued')
|
||||
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides clear queued button when queued count is zero', () => {
|
||||
const wrapper = mountHeader({ queuedCount: 0 })
|
||||
|
||||
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles popover and emits clear history', async () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-12 items-center gap-2 border-b border-interface-stroke px-2"
|
||||
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
|
||||
>
|
||||
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
|
||||
<div class="px-2 text-[14px] font-normal text-text-primary">
|
||||
<span>{{ headerTitle }}</span>
|
||||
<span
|
||||
v-if="showConcurrentIndicator"
|
||||
@@ -17,25 +17,6 @@
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
|
||||
>
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!isCloud" class="flex items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
@@ -97,12 +78,10 @@ defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
(e: 'clearQueued'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const QueueOverlayExpandedStub = defineComponent({
|
||||
name: 'QueueOverlayExpanded',
|
||||
props: {
|
||||
headerTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
|
||||
})
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl({
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
priority: 0
|
||||
})
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
runningTasks: TaskItemImpl[],
|
||||
pendingTasks: TaskItemImpl[]
|
||||
) => {
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
const queueStore = useQueueStore(pinia)
|
||||
queueStore.runningTasks = runningTasks
|
||||
queueStore.pendingTasks = pendingTasks
|
||||
|
||||
return mount(QueueProgressOverlay, {
|
||||
props: {
|
||||
expanded: true
|
||||
},
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
QueueOverlayExpanded: QueueOverlayExpandedStub,
|
||||
QueueOverlayActive: true,
|
||||
ResultGallery: true
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('QueueProgressOverlay', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
})
|
||||
|
||||
it('shows expanded header with running and queued labels', () => {
|
||||
const wrapper = mountComponent(
|
||||
[
|
||||
createTask('running-1', 'in_progress'),
|
||||
createTask('running-2', 'in_progress')
|
||||
],
|
||||
[createTask('pending-1', 'pending')]
|
||||
)
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'2 running, 1 queued'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows only running label when queued count is zero', () => {
|
||||
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'1 running'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows job queue title when there are no active jobs', () => {
|
||||
const wrapper = mountComponent([], [])
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'Job Queue'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -44,6 +44,12 @@
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@view-all-jobs="viewAllJobs"
|
||||
/>
|
||||
|
||||
<QueueOverlayEmpty
|
||||
v-else-if="completionSummary"
|
||||
:summary="completionSummary"
|
||||
@summary-click="onSummaryClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,9 +64,11 @@ import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
@@ -76,7 +84,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'active' | 'expanded'
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -92,7 +100,7 @@ const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -122,20 +130,26 @@ const isExpanded = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const { summary: completionSummary, clearSummary } = useCompletionSummary()
|
||||
const hasCompletionSummary = computed(() => completionSummary.value !== null)
|
||||
|
||||
const runningCount = computed(() => queueStore.runningTasks.length)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const isExecuting = computed(() => !executionStore.isIdle)
|
||||
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
|
||||
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
|
||||
|
||||
const overlayState = computed<OverlayState>(() => {
|
||||
if (isExpanded.value) return 'expanded'
|
||||
if (hasActiveJob.value) return 'active'
|
||||
if (hasCompletionSummary.value) return 'empty'
|
||||
return 'hidden'
|
||||
})
|
||||
|
||||
const showBackground = computed(
|
||||
() =>
|
||||
overlayState.value === 'expanded' ||
|
||||
overlayState.value === 'empty' ||
|
||||
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||
)
|
||||
|
||||
@@ -155,34 +169,11 @@ const bottomRowClass = computed(
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
)
|
||||
const runningJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
|
||||
count: n(runningCount.value)
|
||||
})
|
||||
const headerTitle = computed(() =>
|
||||
hasActiveJob.value
|
||||
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
|
||||
: t('sideToolbar.queueProgressOverlay.jobQueue')
|
||||
)
|
||||
const queuedJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
|
||||
count: n(queuedCount.value)
|
||||
})
|
||||
)
|
||||
const headerTitle = computed(() => {
|
||||
if (!hasActiveJob.value) {
|
||||
return t('sideToolbar.queueProgressOverlay.jobQueue')
|
||||
}
|
||||
|
||||
if (queuedCount.value === 0) {
|
||||
return runningJobsLabel.value
|
||||
}
|
||||
|
||||
if (runningCount.value === 0) {
|
||||
return queuedJobsLabel.value
|
||||
}
|
||||
|
||||
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
|
||||
running: runningJobsLabel.value,
|
||||
queued: queuedJobsLabel.value
|
||||
})
|
||||
})
|
||||
|
||||
const concurrentWorkflowCount = computed(
|
||||
() => executionStore.runningWorkflowCount
|
||||
@@ -239,10 +230,19 @@ const setExpanded = (expanded: boolean) => {
|
||||
isExpanded.value = expanded
|
||||
}
|
||||
|
||||
const openExpandedFromEmpty = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const viewAllJobs = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const onSummaryClick = () => {
|
||||
openExpandedFromEmpty()
|
||||
clearSummary()
|
||||
}
|
||||
|
||||
const openAssetsSidebar = () => {
|
||||
sidebarTabStore.activeSidebarTabId = 'assets'
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
vi.mock('primevue/popover', () => {
|
||||
const PopoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
setup(_, { slots, expose }) {
|
||||
expose({
|
||||
hide: () => undefined,
|
||||
toggle: (_event: Event) => undefined
|
||||
})
|
||||
return () => slots.default?.()
|
||||
}
|
||||
})
|
||||
return { default: PopoverStub }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
import JobFiltersBar from '@/components/queue/job/JobFiltersBar.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
all: 'All',
|
||||
completed: 'Completed'
|
||||
},
|
||||
queue: {
|
||||
jobList: {
|
||||
sortMostRecent: 'Most recent',
|
||||
sortTotalGenerationTime: 'Total generation time'
|
||||
}
|
||||
},
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
filterJobs: 'Filter jobs',
|
||||
filterBy: 'Filter by',
|
||||
sortJobs: 'Sort jobs',
|
||||
sortBy: 'Sort by',
|
||||
showAssets: 'Show assets',
|
||||
showAssetsPanel: 'Show assets panel',
|
||||
filterAllWorkflows: 'All workflows',
|
||||
filterCurrentWorkflow: 'Current workflow'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('JobFiltersBar', () => {
|
||||
it('emits showAssets when the assets icon button is clicked', async () => {
|
||||
const wrapper = mount(JobFiltersBar, {
|
||||
props: {
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
hasFailedJobs: false
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => undefined }
|
||||
}
|
||||
})
|
||||
|
||||
const showAssetsButton = wrapper.get(
|
||||
'button[aria-label="Show assets panel"]'
|
||||
)
|
||||
await showAssetsButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('showAssets')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -127,15 +127,6 @@
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button
|
||||
v-tooltip.top="showAssetsTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -159,7 +150,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showAssets'): void
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
|
||||
(e: 'update:selectedSortMode', value: JobSortMode): void
|
||||
@@ -175,9 +165,6 @@ const filterTooltipConfig = computed(() =>
|
||||
const sortTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
|
||||
)
|
||||
const showAssetsTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
|
||||
)
|
||||
|
||||
// This can be removed when cloud implements /jobs and we switch to it.
|
||||
const showWorkflowFilter = !isCloud
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
useFlatAndCategorizeSelectedItems
|
||||
} from './shared'
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
import TabErrors from './errors/TabErrors.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -41,8 +40,6 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
@@ -105,10 +102,7 @@ const selectedNodeErrors = computed(() =>
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = []
|
||||
if (
|
||||
selectedNodeErrors.value.length &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
if (selectedNodeErrors.value.length) {
|
||||
list.push({
|
||||
label: () => t('g.error'),
|
||||
value: 'error',
|
||||
@@ -116,18 +110,6 @@ const tabs = computed<RightSidePanelTabList>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
hasAnyError.value &&
|
||||
!hasSelection.value &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.errors'),
|
||||
value: 'errors',
|
||||
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||
})
|
||||
}
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
flattedItems.value.length > 1
|
||||
@@ -316,8 +298,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<template v-if="!hasSelection">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
</template>
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
|
||||
/**
|
||||
* ErrorNodeCard displays a single error card inside the error tab.
|
||||
* It shows the node header (ID badge, title, action buttons)
|
||||
* and the list of error items (message, traceback, copy button).
|
||||
*/
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
component: ErrorNodeCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
showNodeIdBadge: { control: 'boolean' }
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[330px] bg-base-surface border border-interface-stroke rounded-lg p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '10',
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "text" is missing.',
|
||||
details: 'Input: text\nExpected: STRING'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: '24',
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "samples" is missing.',
|
||||
details: ''
|
||||
},
|
||||
{
|
||||
message: 'Value "NaN" is not a valid number for "strength".',
|
||||
details: 'Expected: FLOAT [0.0 .. 1.0]'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: '45',
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
|
||||
details: [
|
||||
'Traceback (most recent call last):',
|
||||
' File "ksampler.py", line 142, in sample',
|
||||
' samples = model.apply(latent)',
|
||||
'RuntimeError: CUDA out of memory.'
|
||||
].join('\n'),
|
||||
isRuntimeError: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: '3:15',
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** Single validation error with node ID badge visible */
|
||||
export const WithNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Single validation error without node ID badge */
|
||||
export const WithoutNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: false
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Regular node error — no "Enter subgraph" button */
|
||||
export const WithoutEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
card: multipleErrorsCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime execution error with full traceback */
|
||||
export const RuntimeError: Story = {
|
||||
args: {
|
||||
card: runtimeErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt-level error (no node header) */
|
||||
export const PromptError: Story = {
|
||||
args: {
|
||||
card: promptOnlyCard,
|
||||
showNodeIdBadge: false
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<!-- Card Header (Node ID & Actions) -->
|
||||
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
<span
|
||||
v-if="card.nodeTitle"
|
||||
class="flex-1 text-sm text-muted-foreground truncate font-medium"
|
||||
>
|
||||
{{ card.nodeTitle }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
@click.stop="emit('locateNode', card.nodeId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
<div class="divide-y divide-interface-stroke/20 space-y-4">
|
||||
<!-- Card Content -->
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="error.message"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
|
||||
>
|
||||
{{ error.message }}
|
||||
</p>
|
||||
|
||||
<!-- Traceback / Details -->
|
||||
<div
|
||||
v-if="error.details"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg bg-secondary-background-hover p-2.5 overflow-y-auto border border-interface-stroke/30',
|
||||
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs text-muted-foreground break-words whitespace-pre-wrap font-mono leading-relaxed"
|
||||
>
|
||||
{{ error.details }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full justify-center gap-2 h-8 text-[11px]"
|
||||
@click="handleCopyError(error)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
{{ t('g.copy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
|
||||
const { card, showNodeIdBadge = false } = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleCopyError(error: ErrorItem) {
|
||||
emit(
|
||||
'copyToClipboard',
|
||||
[error.message, error.details].filter(Boolean).join('\n\n')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,218 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
forEachNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: vi.fn(() => ({
|
||||
copyToClipboard: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
fitView: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('TabErrors.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
workflow: 'Workflow',
|
||||
copy: 'Copy'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
promptErrors: {
|
||||
prompt_no_outputs: {
|
||||
desc: 'Prompt has no outputs'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function mountComponent(initialState = {}) {
|
||||
return mount(TabErrors, {
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
FormSearchInput: {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders "no errors" state when store is empty', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('No errors')
|
||||
})
|
||||
|
||||
it('renders prompt-level errors (Group title = error message)', async () => {
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastPromptError: {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'Server Error: No outputs',
|
||||
details: 'Error details'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Group title should be the raw message from store
|
||||
expect(wrapper.text()).toContain('Server Error: No outputs')
|
||||
// Item message should be localized desc
|
||||
expect(wrapper.text()).toContain('Prompt has no outputs')
|
||||
// Details should not be rendered for prompt errors
|
||||
expect(wrapper.text()).not.toContain('Error details')
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by class_type', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'CLIP Text Encode'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'6': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{ message: 'Required input is missing', details: 'Input: text' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).toContain('#6')
|
||||
expect(wrapper.text()).toContain('CLIP Text Encode')
|
||||
expect(wrapper.text()).toContain('Required input is missing')
|
||||
})
|
||||
|
||||
it('renders runtime execution errors from WebSocket', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '10',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'Out of memory',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['Line 1', 'Line 2'],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
expect(wrapper.text()).toContain('#10')
|
||||
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
|
||||
expect(wrapper.text()).toContain('Line 1')
|
||||
})
|
||||
|
||||
it('filters errors based on search query', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [{ message: 'Missing text input' }]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'KSampler',
|
||||
errors: [{ message: 'Out of memory' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
|
||||
const searchInput = wrapper.find('input')
|
||||
await searchInput.setValue('Missing text input')
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).not.toContain('KSampler')
|
||||
})
|
||||
|
||||
it('calls copyToClipboard when copy button is clicked', async () => {
|
||||
const { useCopyToClipboard } =
|
||||
await import('@/composables/useCopyToClipboard')
|
||||
const mockCopy = vi.fn()
|
||||
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
errors: [{ message: 'Test message', details: 'Test details' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Find the copy button (rendered inside ErrorNodeCard)
|
||||
const copyButtons = wrapper.findAll('button')
|
||||
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
|
||||
expect(copyButton).toBeTruthy()
|
||||
await copyButton!.trigger('click')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full min-w-0">
|
||||
<!-- Search bar -->
|
||||
<div
|
||||
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
|
||||
>
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Group by Class Type -->
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.title"
|
||||
:collapse="collapseState[group.title] ?? false"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="collapseState[group.title] = $event"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
|
||||
/>
|
||||
<span class="text-destructive-background-hover truncate">
|
||||
{{ group.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.cards.length > 1"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ group.cards.length }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cards in Group (default slot) -->
|
||||
<div class="px-4 space-y-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="focusNode"
|
||||
@enter-subgraph="enterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
class="m-0 text-sm text-muted-foreground leading-tight break-words"
|
||||
>
|
||||
<template #github>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="inline underline text-inherit text-sm whitespace-nowrap"
|
||||
@click="openGitHubIssues"
|
||||
>
|
||||
{{ t('rightSidePanel.errorHelpGithub') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #support>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="inline underline text-inherit text-sm whitespace-nowrap"
|
||||
@click="contactSupport"
|
||||
>
|
||||
{{ t('rightSidePanel.errorHelpSupport') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
const { filteredGroups } = useErrorGroups(searchQuery, t)
|
||||
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
for (const group of filteredGroups.value) {
|
||||
const hasMatch = group.cards.some(
|
||||
(card) => card.graphNodeId === graphNodeId
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function contactSupport() {
|
||||
useTelemetry()?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
</script>
|
||||
@@ -1,21 +0,0 @@
|
||||
export interface ErrorItem {
|
||||
message: string
|
||||
details?: string
|
||||
isRuntimeError?: boolean
|
||||
}
|
||||
|
||||
export interface ErrorCardData {
|
||||
id: string
|
||||
title: string
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
export interface ErrorGroup {
|
||||
title: string
|
||||
cards: ErrorCardData[]
|
||||
priority: number
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
interface GroupEntry {
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
}
|
||||
|
||||
interface ErrorSearchItem {
|
||||
groupIndex: number
|
||||
cardIndex: number
|
||||
searchableNodeId: string
|
||||
searchableNodeTitle: string
|
||||
searchableMessage: string
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
|
||||
|
||||
function resolveNodeInfo(nodeId: string): {
|
||||
title: string
|
||||
graphNodeId: string | undefined
|
||||
} {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
return {
|
||||
title: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
title: string,
|
||||
priority = 1
|
||||
): Map<string, ErrorCardData> {
|
||||
let entry = groupsMap.get(title)
|
||||
if (!entry) {
|
||||
entry = { priority, cards: new Map() }
|
||||
groupsMap.set(title, entry)
|
||||
}
|
||||
return entry.cards
|
||||
}
|
||||
|
||||
function processPromptError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
if (!executionStore.lastPromptError) return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
cards.set('__prompt__', {
|
||||
id: '__prompt__',
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown
|
||||
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
|
||||
: error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `node-${nodeId}`,
|
||||
title: nodeError.class_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) continue
|
||||
card.errors.push(
|
||||
...nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
const nodeId = String(e.node_id)
|
||||
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
|
||||
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `exec-${nodeId}`,
|
||||
title: e.node_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) return
|
||||
card.errors.push({
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
})
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
return Array.from(groupsMap.entries())
|
||||
.map(([title, groupData]) => ({
|
||||
title,
|
||||
cards: Array.from(groupData.cards.values()),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
}
|
||||
|
||||
function buildErrorGroups(
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
): ErrorGroup[] {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap, executionStore, t)
|
||||
processNodeErrors(groupsMap, executionStore)
|
||||
processExecutionError(groupsMap, executionStore)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
}
|
||||
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
|
||||
if (!query) return groups
|
||||
|
||||
const searchableList: ErrorSearchItem[] = []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const group = groups[gi]!
|
||||
for (let ci = 0; ci < group.cards.length; ci++) {
|
||||
const card = group.cards[ci]!
|
||||
searchableList.push({
|
||||
groupIndex: gi,
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
|
||||
keys: [
|
||||
{ name: 'searchableNodeId', weight: 0.3 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.3 },
|
||||
{ name: 'searchableMessage', weight: 0.3 },
|
||||
{ name: 'searchableDetails', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3
|
||||
}
|
||||
|
||||
const fuse = new Fuse(searchableList, fuseOptions)
|
||||
const results = fuse.search(query)
|
||||
|
||||
const matchedCardKeys = new Set(
|
||||
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
|
||||
)
|
||||
|
||||
return groups
|
||||
.map((group, gi) => ({
|
||||
...group,
|
||||
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
|
||||
}))
|
||||
.filter((group) => group.cards.length > 0)
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
searchQuery: Ref<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const errorGroups = computed<ErrorGroup[]>(() =>
|
||||
buildErrorGroups(executionStore, t)
|
||||
)
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(errorGroups.value, query)
|
||||
})
|
||||
|
||||
return {
|
||||
errorGroups,
|
||||
filteredGroups
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
@@ -64,9 +57,6 @@ watchEffect(() => (widgets.value = widgetsProp))
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
@@ -110,11 +100,6 @@ const targetNode = computed<LGraphNode | null>(() => {
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
@@ -133,38 +118,15 @@ function handleLocateNode() {
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToErrorTab() {
|
||||
if (!targetNode.value) return
|
||||
if (!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) return
|
||||
rightSidePanelStore.focusedErrorNodeId = String(targetNode.value.id)
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
function handleWidgetValueUpdate(
|
||||
widget: IBaseWidget,
|
||||
newValue: string | number | boolean | object
|
||||
) {
|
||||
widget.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleResetAllWidgets() {
|
||||
for (const { widget, node: widgetNode } of widgetsProp) {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||
const defaultValue = getWidgetDefaultValue(spec)
|
||||
if (defaultValue !== undefined) {
|
||||
writeWidgetValue(widget, defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
if (newValue === undefined) return
|
||||
writeWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
writeWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
widgetsContainer,
|
||||
rootElement
|
||||
@@ -180,20 +142,9 @@ defineExpose({
|
||||
:tooltip
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<i
|
||||
v-if="nodeHasError"
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'truncate',
|
||||
nodeHasError && 'text-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="truncate">
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
@@ -206,26 +157,6 @@ defineExpose({
|
||||
{{ parentGroup.title }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="nodeHasError"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 rounded-lg text-sm"
|
||||
@click.stop="navigateToErrorTab"
|
||||
>
|
||||
{{ t('rightSidePanel.seeError') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isEmpty"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.resetAllParameters')"
|
||||
:aria-label="t('rightSidePanel.resetAllParameters')"
|
||||
@click.stop="handleResetAllWidgets"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canShowLocateButton"
|
||||
variant="textonly"
|
||||
@@ -258,7 +189,6 @@ defineExpose({
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
|
||||
@reset-to-default="handleWidgetReset(widget, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { Slots } from 'vue'
|
||||
import { h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||
mockGetInputSpecForWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
getInputSpecForWidget: mockGetInputSpecForWidget
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: { setDirty: vi.fn() }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
|
||||
useFavoritedWidgetsStore: () => ({
|
||||
isFavorited: vi.fn().mockReturnValue(false),
|
||||
toggleFavorite: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
prompt: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||
h('div', slots.default?.({ close: () => {} }))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
rename: 'Rename',
|
||||
enterNewName: 'Enter new name'
|
||||
},
|
||||
rightSidePanel: {
|
||||
hideInput: 'Hide input',
|
||||
showInput: 'Show input',
|
||||
addFavorite: 'Favorite',
|
||||
removeFavorite: 'Unfavorite',
|
||||
resetToDefault: 'Reset to default'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('WidgetActions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'INT',
|
||||
default: 42
|
||||
})
|
||||
})
|
||||
|
||||
function createMockWidget(
|
||||
value: number = 100,
|
||||
callback?: () => void
|
||||
): IBaseWidget {
|
||||
return {
|
||||
name: 'test_widget',
|
||||
type: 'number',
|
||||
value,
|
||||
label: 'Test Widget',
|
||||
options: {},
|
||||
y: 0,
|
||||
callback
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'TestNode'
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
|
||||
return mount(WidgetActions, {
|
||||
props: {
|
||||
widget,
|
||||
node,
|
||||
label: 'Test Widget'
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('shows reset button when widget has default value', () => {
|
||||
const widget = createMockWidget()
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
expect(resetButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits resetToDefault with default value when reset button clicked', async () => {
|
||||
const widget = createMockWidget(100)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
await resetButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
|
||||
expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
|
||||
})
|
||||
|
||||
it('disables reset button when value equals default', () => {
|
||||
const widget = createMockWidget(42)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
expect(resetButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not show reset button when no default value exists', () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
|
||||
const widget = createMockWidget(100)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
expect(resetButton).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses fallback default for INT type without explicit default', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'INT'
|
||||
})
|
||||
|
||||
const widget = createMockWidget(100)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
await resetButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
|
||||
})
|
||||
|
||||
it('uses first option as default for combo without explicit default', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'COMBO',
|
||||
options: ['option1', 'option2', 'option3']
|
||||
})
|
||||
|
||||
const widget = createMockWidget(100)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
await resetButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -15,10 +14,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
const {
|
||||
widget,
|
||||
@@ -32,15 +28,10 @@ const {
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
resetToDefault: [value: WidgetValue]
|
||||
}>()
|
||||
|
||||
const label = defineModel<string>('label', { required: true })
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -52,19 +43,6 @@ const isFavorited = computed(() =>
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
||||
)
|
||||
|
||||
const inputSpec = computed(() =>
|
||||
nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
)
|
||||
|
||||
const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
|
||||
|
||||
const hasDefault = computed(() => defaultValue.value !== undefined)
|
||||
|
||||
const isCurrentValueDefault = computed(() => {
|
||||
if (!hasDefault.value) return true
|
||||
return isEqual(widget.value, defaultValue.value)
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
@@ -119,11 +97,6 @@ function handleToggleFavorite() {
|
||||
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
||||
}
|
||||
|
||||
function handleResetToDefault() {
|
||||
if (!hasDefault.value) return
|
||||
emit('resetToDefault', defaultValue.value)
|
||||
}
|
||||
|
||||
const buttonClasses = cn([
|
||||
'border-none bg-transparent',
|
||||
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
||||
@@ -189,21 +162,6 @@ const buttonClasses = cn([
|
||||
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasDefault"
|
||||
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
|
||||
:disabled="isCurrentValueDefault"
|
||||
@click="
|
||||
() => {
|
||||
handleResetToDefault()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,6 @@ import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
@@ -43,8 +42,7 @@ const {
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:widgetValue': [value: WidgetValue]
|
||||
resetToDefault: [value: WidgetValue]
|
||||
'update:widgetValue': [value: string | number | boolean | object]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -86,7 +84,7 @@ const favoriteNode = computed(() =>
|
||||
|
||||
const widgetValue = computed({
|
||||
get: () => widget.value,
|
||||
set: (newValue: WidgetValue) => {
|
||||
set: (newValue: string | number | boolean | object) => {
|
||||
emit('update:widgetValue', newValue)
|
||||
}
|
||||
})
|
||||
@@ -156,7 +154,6 @@ const displayLabel = customRef((track, trigger) => {
|
||||
:node="node"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isShownOnParents"
|
||||
@reset-to-default="emit('resetToDefault', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
|
||||
const mockStoreRefs = vi.hoisted(() => ({
|
||||
visible: { value: false },
|
||||
newSearchBoxEnabled: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async () => {
|
||||
const actual = await vi.importActual('pinia')
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
storeToRefs: () => mockStoreRefs
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/workspace/searchBoxStore', () => ({
|
||||
useSearchBoxStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({
|
||||
getCanvasCenter: vi.fn(() => [0, 0]),
|
||||
addNodeOnGraph: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: null,
|
||||
getCanvas: vi.fn(() => ({
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
renderLinks: []
|
||||
}
|
||||
}))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeSearchService: {
|
||||
nodeFilters: [],
|
||||
inputTypeFilter: {},
|
||||
outputTypeFilter: {}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const NodeSearchBoxStub = defineComponent({
|
||||
name: 'NodeSearchBox',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
},
|
||||
template: '<div class="node-search-box" />'
|
||||
})
|
||||
|
||||
function createFilter(
|
||||
id: string,
|
||||
value: string
|
||||
): FuseFilterWithValue<ComfyNodeDefImpl, string> {
|
||||
return {
|
||||
filterDef: { id } as FuseFilter<ComfyNodeDefImpl, string>,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
describe('NodeSearchBoxPopover', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockStoreRefs.visible.value = false
|
||||
})
|
||||
|
||||
const mountComponent = () => {
|
||||
return mount(NodeSearchBoxPopover, {
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue],
|
||||
stubs: {
|
||||
NodeSearchBox: NodeSearchBoxStub,
|
||||
Dialog: {
|
||||
template: '<div><slot name="container" /></div>',
|
||||
props: ['visible', 'modal', 'dismissableMask', 'pt']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('addFilter duplicate prevention', () => {
|
||||
it('should add a filter when no duplicates exist', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const filters = searchBox.props('filters') as FuseFilterWithValue<
|
||||
ComfyNodeDefImpl,
|
||||
string
|
||||
>[]
|
||||
expect(filters).toHaveLength(1)
|
||||
expect(filters[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
filterDef: expect.objectContaining({ id: 'outputType' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not add a duplicate filter with same id and value', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should allow filters with same id but different values', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should allow filters with different ids but same value', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -71,12 +71,7 @@ function getNewNodeLocation(): Point {
|
||||
}
|
||||
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
|
||||
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
const isDuplicate = nodeFilters.value.some(
|
||||
(f) => f.filterDef.id === filter.filterDef.id && f.value === filter.value
|
||||
)
|
||||
if (!isDuplicate) {
|
||||
nodeFilters.value.push(filter)
|
||||
}
|
||||
nodeFilters.value.push(filter)
|
||||
}
|
||||
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
nodeFilters.value = nodeFilters.value.filter(
|
||||
|
||||
@@ -29,7 +29,7 @@ import Toast from 'primevue/toast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -69,7 +69,7 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
@@ -80,8 +80,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() =>
|
||||
import('../../platform/workspace/components/CurrentUserPopoverWorkspace.vue')
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
)
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
|
||||
@@ -207,8 +207,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/platform/workspace/components/WorkspaceSwitcherPopover.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
|
||||
@@ -112,9 +112,9 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import type {
|
||||
SubscriptionTier,
|
||||
WorkspaceRole,
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
import { useWorkspaceBilling } from './useWorkspaceBilling'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from '../../../composables/billing/types'
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Adapter for workspace-scoped billing via /billing/* endpoints.
|
||||
@@ -1,56 +0,0 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) return
|
||||
|
||||
if (canvas.graph !== targetGraph) {
|
||||
canvas.subgraph = targetGraph.isRootGraph
|
||||
? undefined
|
||||
: (targetGraph as Subgraph)
|
||||
canvas.setGraph(targetGraph)
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Double RAF to wait for LiteGraph's internal canvas frame cycle
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function useFocusNode() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
async function focusNode(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
useLitegraphService().fitView()
|
||||
}
|
||||
|
||||
return {
|
||||
focusNode,
|
||||
enterSubgraph
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export interface SafeWidgetData {
|
||||
hasLayoutSize?: boolean
|
||||
/** Whether widget is a DOM widget */
|
||||
isDOMWidget?: boolean
|
||||
/** Node type (for subgraph promoted widgets) */
|
||||
nodeType?: string
|
||||
/**
|
||||
* Widget options needed for render decisions.
|
||||
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
|
||||
@@ -119,6 +121,12 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
||||
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
|
||||
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
|
||||
return subNode?.type
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
||||
*/
|
||||
@@ -127,6 +135,8 @@ interface SharedWidgetEnhancements {
|
||||
controlWidget?: SafeControlWidget
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Node type (for subgraph promoted widgets) */
|
||||
nodeType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +152,8 @@ export function getSharedWidgetEnhancements(
|
||||
|
||||
return {
|
||||
controlWidget: getControlWidget(widget),
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
||||
nodeType: getNodeType(node, widget)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
289
src/composables/queue/useCompletionSummary.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
type MockTask = {
|
||||
displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending'
|
||||
executionEndTimestamp?: number
|
||||
previewOutput?: {
|
||||
isImage: boolean
|
||||
urlWithTimestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/queueStore', () => {
|
||||
const state = reactive({
|
||||
runningTasks: [] as MockTask[],
|
||||
historyTasks: [] as MockTask[]
|
||||
})
|
||||
|
||||
return {
|
||||
useQueueStore: () => state
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/executionStore', () => {
|
||||
const state = reactive({
|
||||
isIdle: true
|
||||
})
|
||||
|
||||
return {
|
||||
useExecutionStore: () => state
|
||||
}
|
||||
})
|
||||
|
||||
describe('useCompletionSummary', () => {
|
||||
const queueStore = () =>
|
||||
useQueueStore() as {
|
||||
runningTasks: MockTask[]
|
||||
historyTasks: MockTask[]
|
||||
}
|
||||
const executionStore = () => useExecutionStore() as { isIdle: boolean }
|
||||
|
||||
const resetState = () => {
|
||||
queueStore().runningTasks = []
|
||||
queueStore().historyTasks = []
|
||||
executionStore().isIdle = true
|
||||
}
|
||||
|
||||
const createTask = (
|
||||
options: {
|
||||
state?: MockTask['displayStatus']
|
||||
ts?: number
|
||||
previewUrl?: string
|
||||
isImage?: boolean
|
||||
} = {}
|
||||
): MockTask => {
|
||||
const {
|
||||
state = 'Completed',
|
||||
ts = Date.now(),
|
||||
previewUrl,
|
||||
isImage = true
|
||||
} = options
|
||||
|
||||
const task: MockTask = {
|
||||
displayStatus: state,
|
||||
executionEndTimestamp: ts
|
||||
}
|
||||
|
||||
if (previewUrl) {
|
||||
task.previewOutput = {
|
||||
isImage,
|
||||
urlWithTimestamp: previewUrl
|
||||
}
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
const runBatch = async (options: {
|
||||
start: number
|
||||
finish: number
|
||||
tasks: MockTask[]
|
||||
}) => {
|
||||
const { start, finish, tasks } = options
|
||||
|
||||
vi.setSystemTime(start)
|
||||
executionStore().isIdle = false
|
||||
await nextTick()
|
||||
|
||||
vi.setSystemTime(finish)
|
||||
queueStore().historyTasks = tasks
|
||||
executionStore().isIdle = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
resetState()
|
||||
})
|
||||
|
||||
it('summarizes the most recent batch and auto clears after the dismiss delay', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 1_000
|
||||
const finish = 2_000
|
||||
|
||||
const tasks = [
|
||||
createTask({ ts: start - 100, previewUrl: 'ignored-old' }),
|
||||
createTask({ ts: start + 10, previewUrl: 'img-1' }),
|
||||
createTask({ ts: start + 20, previewUrl: 'img-2' }),
|
||||
createTask({ ts: start + 30, previewUrl: 'img-3' }),
|
||||
createTask({ ts: start + 40, previewUrl: 'img-4' }),
|
||||
createTask({ state: 'Failed', ts: start + 50 })
|
||||
]
|
||||
|
||||
await runBatch({ start, finish, tasks })
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'mixed',
|
||||
completedCount: 4,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['img-1', 'img-2', 'img-3']
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(6000)
|
||||
await nextTick()
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
|
||||
it('reports allFailed when every task in the batch failed', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 10_000
|
||||
const finish = 10_200
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ state: 'Failed', ts: start + 25 }),
|
||||
createTask({ state: 'Failed', ts: start + 50 })
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 2,
|
||||
thumbnailUrls: []
|
||||
})
|
||||
})
|
||||
|
||||
it('treats cancelled tasks as failures and skips non-image previews', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 15_000
|
||||
const finish = 15_200
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ ts: start + 25, previewUrl: 'img-1' }),
|
||||
createTask({
|
||||
state: 'Cancelled',
|
||||
ts: start + 50,
|
||||
previewUrl: 'thumb-ignore',
|
||||
isImage: false
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'mixed',
|
||||
completedCount: 1,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['img-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('clearSummary dismisses the banner immediately and still tracks future batches', async () => {
|
||||
const { summary, clearSummary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
await runBatch({
|
||||
start: 5_000,
|
||||
finish: 5_100,
|
||||
tasks: [createTask({ ts: 5_050, previewUrl: 'img-1' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-1']
|
||||
})
|
||||
|
||||
clearSummary()
|
||||
expect(summary.value).toBeNull()
|
||||
|
||||
await runBatch({
|
||||
start: 6_000,
|
||||
finish: 6_150,
|
||||
tasks: [createTask({ ts: 6_075, previewUrl: 'img-2' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-2']
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores batches that have no finished tasks after the active period started', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 20_000
|
||||
const finish = 20_500
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [createTask({ ts: start - 1, previewUrl: 'too-early' })]
|
||||
})
|
||||
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the active period from running tasks when execution is already idle', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 25_000
|
||||
vi.setSystemTime(start)
|
||||
queueStore().runningTasks = [
|
||||
createTask({ state: 'Running', ts: start + 1 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
const finish = start + 150
|
||||
vi.setSystemTime(finish)
|
||||
queueStore().historyTasks = [
|
||||
createTask({ ts: finish - 10, previewUrl: 'img-running-trigger' })
|
||||
]
|
||||
queueStore().runningTasks = []
|
||||
await nextTick()
|
||||
|
||||
expect(summary.value).toEqual({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: ['img-running-trigger']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit a summary when every finished task is still running or pending', async () => {
|
||||
const { summary } = useCompletionSummary()
|
||||
await nextTick()
|
||||
|
||||
const start = 30_000
|
||||
const finish = 30_300
|
||||
|
||||
await runBatch({
|
||||
start,
|
||||
finish,
|
||||
tasks: [
|
||||
createTask({ state: 'Running', ts: start + 20 }),
|
||||
createTask({ state: 'Pending', ts: start + 40 })
|
||||
]
|
||||
})
|
||||
|
||||
expect(summary.value).toBeNull()
|
||||
})
|
||||
})
|
||||
116
src/composables/queue/useCompletionSummary.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
export type CompletionSummaryMode = 'allSuccess' | 'mixed' | 'allFailed'
|
||||
|
||||
export type CompletionSummary = {
|
||||
mode: CompletionSummaryMode
|
||||
completedCount: number
|
||||
failedCount: number
|
||||
thumbnailUrls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks queue activity transitions and exposes a short-lived summary of the
|
||||
* most recent generation batch.
|
||||
*/
|
||||
export const useCompletionSummary = () => {
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const isActive = computed(
|
||||
() => queueStore.runningTasks.length > 0 || !executionStore.isIdle
|
||||
)
|
||||
|
||||
const lastActiveStartTs = ref<number | null>(null)
|
||||
const _summary = ref<CompletionSummary | null>(null)
|
||||
const dismissTimer = ref<number | null>(null)
|
||||
|
||||
const clearDismissTimer = () => {
|
||||
if (dismissTimer.value !== null) {
|
||||
clearTimeout(dismissTimer.value)
|
||||
dismissTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const startDismissTimer = () => {
|
||||
clearDismissTimer()
|
||||
dismissTimer.value = window.setTimeout(() => {
|
||||
_summary.value = null
|
||||
dismissTimer.value = null
|
||||
}, 6000)
|
||||
}
|
||||
|
||||
const clearSummary = () => {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
}
|
||||
|
||||
watch(
|
||||
isActive,
|
||||
(active, prev) => {
|
||||
if (!prev && active) {
|
||||
lastActiveStartTs.value = Date.now()
|
||||
}
|
||||
if (prev && !active) {
|
||||
const start = lastActiveStartTs.value ?? 0
|
||||
const finished = queueStore.historyTasks.filter((t) => {
|
||||
const ts = t.executionEndTimestamp
|
||||
return typeof ts === 'number' && ts >= start
|
||||
})
|
||||
|
||||
if (!finished.length) {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
return
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let failedCount = 0
|
||||
const imagePreviews: string[] = []
|
||||
|
||||
for (const task of finished) {
|
||||
const state = jobStateFromTask(task, false)
|
||||
if (state === 'completed') {
|
||||
completedCount++
|
||||
const preview = task.previewOutput
|
||||
if (preview?.isImage) {
|
||||
imagePreviews.push(preview.urlWithTimestamp)
|
||||
}
|
||||
} else if (state === 'failed') {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (completedCount === 0 && failedCount === 0) {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
return
|
||||
}
|
||||
|
||||
let mode: CompletionSummaryMode = 'mixed'
|
||||
if (failedCount === 0) mode = 'allSuccess'
|
||||
else if (completedCount === 0) mode = 'allFailed'
|
||||
|
||||
_summary.value = {
|
||||
mode,
|
||||
completedCount,
|
||||
failedCount,
|
||||
thumbnailUrls: imagePreviews.slice(0, 3)
|
||||
}
|
||||
startDismissTimer()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const summary = computed(() => _summary.value)
|
||||
|
||||
return {
|
||||
summary,
|
||||
clearSummary
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
@@ -255,6 +256,78 @@ describe('useJobList', () => {
|
||||
return api!
|
||||
}
|
||||
|
||||
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ promptId: '1', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
await flush()
|
||||
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: true })
|
||||
)
|
||||
|
||||
vi.mocked(buildJobDisplay).mockClear()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flush()
|
||||
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('removes pending hint immediately when the task leaves the queue', async () => {
|
||||
vi.useFakeTimers()
|
||||
const taskId = '2'
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ promptId: taskId, queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
await flush()
|
||||
jobItems.value
|
||||
|
||||
queueStoreMock.pendingTasks = []
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
vi.mocked(buildJobDisplay).mockClear()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ promptId: taskId, queueIndex: 2, mockState: 'pending' })
|
||||
]
|
||||
await flush()
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up timeouts on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ promptId: '3', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
initComposable()
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts all tasks by create time', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useQueueNotificationBanners } from '@/composables/queue/useQueueNotificationBanners'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
const mockApi = vi.hoisted(() => new EventTarget())
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mockApi
|
||||
}))
|
||||
|
||||
type MockTask = {
|
||||
displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending'
|
||||
executionEndTimestamp?: number
|
||||
previewOutput?: {
|
||||
isImage: boolean
|
||||
urlWithTimestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/queueStore', () => {
|
||||
const state = reactive({
|
||||
pendingTasks: [] as MockTask[],
|
||||
runningTasks: [] as MockTask[],
|
||||
historyTasks: [] as MockTask[]
|
||||
})
|
||||
|
||||
return {
|
||||
useQueueStore: () => state
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/executionStore', () => {
|
||||
const state = reactive({
|
||||
isIdle: true
|
||||
})
|
||||
|
||||
return {
|
||||
useExecutionStore: () => state
|
||||
}
|
||||
})
|
||||
|
||||
const mountComposable = () => {
|
||||
let composable: ReturnType<typeof useQueueNotificationBanners>
|
||||
const wrapper = mount({
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useQueueNotificationBanners()
|
||||
return {}
|
||||
}
|
||||
})
|
||||
return { wrapper, composable: composable! }
|
||||
}
|
||||
|
||||
describe(useQueueNotificationBanners, () => {
|
||||
const queueStore = () =>
|
||||
useQueueStore() as {
|
||||
pendingTasks: MockTask[]
|
||||
runningTasks: MockTask[]
|
||||
historyTasks: MockTask[]
|
||||
}
|
||||
const executionStore = () => useExecutionStore() as { isIdle: boolean }
|
||||
|
||||
const resetState = () => {
|
||||
queueStore().pendingTasks = []
|
||||
queueStore().runningTasks = []
|
||||
queueStore().historyTasks = []
|
||||
executionStore().isIdle = true
|
||||
}
|
||||
|
||||
const createTask = (
|
||||
options: {
|
||||
state?: MockTask['displayStatus']
|
||||
ts?: number
|
||||
previewUrl?: string
|
||||
isImage?: boolean
|
||||
} = {}
|
||||
): MockTask => {
|
||||
const {
|
||||
state = 'Completed',
|
||||
ts = Date.now(),
|
||||
previewUrl,
|
||||
isImage = true
|
||||
} = options
|
||||
|
||||
const task: MockTask = {
|
||||
displayStatus: state,
|
||||
executionEndTimestamp: ts
|
||||
}
|
||||
|
||||
if (previewUrl) {
|
||||
task.previewOutput = {
|
||||
isImage,
|
||||
urlWithTimestamp: previewUrl
|
||||
}
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
const runBatch = async (options: {
|
||||
start: number
|
||||
finish: number
|
||||
tasks: MockTask[]
|
||||
}) => {
|
||||
const { start, finish, tasks } = options
|
||||
|
||||
vi.setSystemTime(start)
|
||||
executionStore().isIdle = false
|
||||
await nextTick()
|
||||
|
||||
vi.setSystemTime(finish)
|
||||
queueStore().historyTasks = tasks
|
||||
executionStore().isIdle = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
resetState()
|
||||
})
|
||||
|
||||
it('shows queued notifications from promptQueued events', async () => {
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
;(api as unknown as EventTarget).dispatchEvent(
|
||||
new CustomEvent('promptQueued', { detail: { batchCount: 4 } })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 4
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await nextTick()
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows queued pending then queued confirmation', async () => {
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
;(api as unknown as EventTarget).dispatchEvent(
|
||||
new CustomEvent('promptQueueing', {
|
||||
detail: { requestId: 1, batchCount: 2 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queuedPending',
|
||||
count: 2,
|
||||
requestId: 1
|
||||
})
|
||||
|
||||
;(api as unknown as EventTarget).dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { requestId: 1, batchCount: 2 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 2,
|
||||
requestId: 1
|
||||
})
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to 1 when queued batch count is invalid', async () => {
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
;(api as unknown as EventTarget).dispatchEvent(
|
||||
new CustomEvent('promptQueued', { detail: { batchCount: 0 } })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 1
|
||||
})
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a completed notification from a finished batch', async () => {
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 1_000,
|
||||
finish: 1_200,
|
||||
tasks: [
|
||||
createTask({
|
||||
ts: 1_050,
|
||||
previewUrl: 'https://example.com/preview.png'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'completed',
|
||||
count: 1,
|
||||
thumbnailUrls: ['https://example.com/preview.png']
|
||||
})
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows one completion notification when history updates after queue becomes idle', async () => {
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
vi.setSystemTime(4_000)
|
||||
executionStore().isIdle = false
|
||||
await nextTick()
|
||||
|
||||
vi.setSystemTime(4_100)
|
||||
executionStore().isIdle = true
|
||||
queueStore().historyTasks = []
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
|
||||
queueStore().historyTasks = [
|
||||
createTask({
|
||||
ts: 4_050,
|
||||
previewUrl: 'https://example.com/race-preview.png'
|
||||
})
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'completed',
|
||||
count: 1,
|
||||
thumbnailUrls: ['https://example.com/race-preview.png']
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await nextTick()
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await nextTick()
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('queues both completed and failed notifications for mixed batches', async () => {
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 2_000,
|
||||
finish: 2_200,
|
||||
tasks: [
|
||||
createTask({
|
||||
ts: 2_050,
|
||||
previewUrl: 'https://example.com/result.png'
|
||||
}),
|
||||
createTask({ ts: 2_060 }),
|
||||
createTask({ ts: 2_070 }),
|
||||
createTask({ state: 'Failed', ts: 2_080 })
|
||||
]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'completed',
|
||||
count: 3,
|
||||
thumbnailUrls: ['https://example.com/result.png']
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'failed',
|
||||
count: 1
|
||||
})
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses up to two completion thumbnails for notification icon previews', async () => {
|
||||
const { wrapper, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 3_000,
|
||||
finish: 3_300,
|
||||
tasks: [
|
||||
createTask({
|
||||
ts: 3_050,
|
||||
previewUrl: 'https://example.com/preview-1.png'
|
||||
}),
|
||||
createTask({
|
||||
ts: 3_060,
|
||||
previewUrl: 'https://example.com/preview-2.png'
|
||||
}),
|
||||
createTask({
|
||||
ts: 3_070,
|
||||
previewUrl: 'https://example.com/preview-3.png'
|
||||
}),
|
||||
createTask({
|
||||
ts: 3_080,
|
||||
previewUrl: 'https://example.com/preview-4.png'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
'https://example.com/preview-1.png',
|
||||
'https://example.com/preview-2.png'
|
||||
]
|
||||
})
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,317 +0,0 @@
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import type {
|
||||
PromptQueuedEventPayload,
|
||||
PromptQueueingEventPayload
|
||||
} from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
const BANNER_DISMISS_DELAY_MS = 4000
|
||||
const MAX_COMPLETION_THUMBNAILS = 2
|
||||
|
||||
type QueueQueuedNotificationType = 'queuedPending' | 'queued'
|
||||
|
||||
type QueueQueuedNotification = {
|
||||
type: QueueQueuedNotificationType
|
||||
count: number
|
||||
requestId?: number
|
||||
}
|
||||
|
||||
type QueueCompletedNotification = {
|
||||
type: 'completed'
|
||||
count: number
|
||||
thumbnailUrls?: string[]
|
||||
}
|
||||
|
||||
type QueueFailedNotification = {
|
||||
type: 'failed'
|
||||
count: number
|
||||
}
|
||||
|
||||
export type QueueNotificationBanner =
|
||||
| QueueQueuedNotification
|
||||
| QueueCompletedNotification
|
||||
| QueueFailedNotification
|
||||
|
||||
const sanitizeCount = (value: number | undefined) => {
|
||||
if (!(typeof value === 'number' && value > 0)) {
|
||||
return 1
|
||||
}
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
export const useQueueNotificationBanners = () => {
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const pendingNotifications = ref<QueueNotificationBanner[]>([])
|
||||
const activeNotification = ref<QueueNotificationBanner | null>(null)
|
||||
const dismissTimer = ref<number | null>(null)
|
||||
const lastActiveStartTs = ref<number | null>(null)
|
||||
let stopIdleHistoryWatch: (() => void) | null = null
|
||||
let idleCompletionScheduleToken = 0
|
||||
const isQueueActive = computed(
|
||||
() =>
|
||||
queueStore.pendingTasks.length > 0 ||
|
||||
queueStore.runningTasks.length > 0 ||
|
||||
!executionStore.isIdle
|
||||
)
|
||||
|
||||
const clearIdleCompletionHooks = () => {
|
||||
idleCompletionScheduleToken++
|
||||
if (!stopIdleHistoryWatch) {
|
||||
return
|
||||
}
|
||||
stopIdleHistoryWatch()
|
||||
stopIdleHistoryWatch = null
|
||||
}
|
||||
|
||||
const clearDismissTimer = () => {
|
||||
if (dismissTimer.value === null) {
|
||||
return
|
||||
}
|
||||
clearTimeout(dismissTimer.value)
|
||||
dismissTimer.value = null
|
||||
}
|
||||
|
||||
const dismissActiveNotification = () => {
|
||||
activeNotification.value = null
|
||||
dismissTimer.value = null
|
||||
showNextNotification()
|
||||
}
|
||||
|
||||
const showNextNotification = () => {
|
||||
if (activeNotification.value !== null) {
|
||||
return
|
||||
}
|
||||
const [nextNotification, ...rest] = pendingNotifications.value
|
||||
pendingNotifications.value = rest
|
||||
if (!nextNotification) {
|
||||
return
|
||||
}
|
||||
|
||||
activeNotification.value = nextNotification
|
||||
clearDismissTimer()
|
||||
dismissTimer.value = window.setTimeout(
|
||||
dismissActiveNotification,
|
||||
BANNER_DISMISS_DELAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
const queueNotification = (notification: QueueNotificationBanner) => {
|
||||
pendingNotifications.value = [...pendingNotifications.value, notification]
|
||||
showNextNotification()
|
||||
}
|
||||
|
||||
const toQueueLifecycleNotification = (
|
||||
type: QueueQueuedNotificationType,
|
||||
count: number,
|
||||
requestId?: number
|
||||
): QueueQueuedNotification => {
|
||||
if (requestId === undefined) {
|
||||
return {
|
||||
type,
|
||||
count
|
||||
}
|
||||
}
|
||||
return {
|
||||
type,
|
||||
count,
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
const toCompletedNotification = (
|
||||
count: number,
|
||||
thumbnailUrls: string[]
|
||||
): QueueCompletedNotification => ({
|
||||
type: 'completed',
|
||||
count,
|
||||
thumbnailUrls: thumbnailUrls.slice(0, MAX_COMPLETION_THUMBNAILS)
|
||||
})
|
||||
|
||||
const toFailedNotification = (count: number): QueueFailedNotification => ({
|
||||
type: 'failed',
|
||||
count
|
||||
})
|
||||
|
||||
const convertQueuedPendingToQueued = (
|
||||
requestId: number | undefined,
|
||||
count: number
|
||||
) => {
|
||||
if (
|
||||
activeNotification.value?.type === 'queuedPending' &&
|
||||
(requestId === undefined ||
|
||||
activeNotification.value.requestId === requestId)
|
||||
) {
|
||||
activeNotification.value = toQueueLifecycleNotification(
|
||||
'queued',
|
||||
count,
|
||||
requestId
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const pendingIndex = pendingNotifications.value.findIndex(
|
||||
(notification) =>
|
||||
notification.type === 'queuedPending' &&
|
||||
(requestId === undefined || notification.requestId === requestId)
|
||||
)
|
||||
|
||||
if (pendingIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const queuedPendingNotification = pendingNotifications.value[pendingIndex]
|
||||
if (
|
||||
queuedPendingNotification === undefined ||
|
||||
queuedPendingNotification.type !== 'queuedPending'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
pendingNotifications.value = [
|
||||
...pendingNotifications.value.slice(0, pendingIndex),
|
||||
toQueueLifecycleNotification(
|
||||
'queued',
|
||||
count,
|
||||
queuedPendingNotification.requestId
|
||||
),
|
||||
...pendingNotifications.value.slice(pendingIndex + 1)
|
||||
]
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handlePromptQueueing = (
|
||||
event: CustomEvent<PromptQueueingEventPayload>
|
||||
) => {
|
||||
const payload = event.detail
|
||||
const count = sanitizeCount(payload?.batchCount)
|
||||
queueNotification(
|
||||
toQueueLifecycleNotification('queuedPending', count, payload?.requestId)
|
||||
)
|
||||
}
|
||||
|
||||
const handlePromptQueued = (event: CustomEvent<PromptQueuedEventPayload>) => {
|
||||
const payload = event.detail
|
||||
const count = sanitizeCount(payload?.batchCount)
|
||||
const handled = convertQueuedPendingToQueued(payload?.requestId, count)
|
||||
if (!handled) {
|
||||
queueNotification(
|
||||
toQueueLifecycleNotification('queued', count, payload?.requestId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
api.addEventListener('promptQueueing', handlePromptQueueing)
|
||||
api.addEventListener('promptQueued', handlePromptQueued)
|
||||
|
||||
const queueCompletionBatchNotifications = () => {
|
||||
const startTs = lastActiveStartTs.value ?? 0
|
||||
const finishedTasks = queueStore.historyTasks.filter((task) => {
|
||||
const ts = task.executionEndTimestamp
|
||||
return typeof ts === 'number' && ts >= startTs
|
||||
})
|
||||
|
||||
if (!finishedTasks.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let failedCount = 0
|
||||
const imagePreviews: string[] = []
|
||||
|
||||
for (const task of finishedTasks) {
|
||||
const state = jobStateFromTask(task, false)
|
||||
if (state === 'completed') {
|
||||
completedCount++
|
||||
const preview = task.previewOutput
|
||||
if (preview?.isImage) {
|
||||
imagePreviews.push(preview.urlWithTimestamp)
|
||||
}
|
||||
} else if (state === 'failed') {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (completedCount > 0) {
|
||||
queueNotification(toCompletedNotification(completedCount, imagePreviews))
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
queueNotification(toFailedNotification(failedCount))
|
||||
}
|
||||
|
||||
return completedCount > 0 || failedCount > 0
|
||||
}
|
||||
|
||||
const scheduleIdleCompletionBatchNotifications = () => {
|
||||
clearIdleCompletionHooks()
|
||||
const scheduleToken = idleCompletionScheduleToken
|
||||
const startTsSnapshot = lastActiveStartTs.value
|
||||
|
||||
const isStillSameIdleWindow = () =>
|
||||
scheduleToken === idleCompletionScheduleToken &&
|
||||
!isQueueActive.value &&
|
||||
lastActiveStartTs.value === startTsSnapshot
|
||||
|
||||
stopIdleHistoryWatch = watch(
|
||||
() => queueStore.historyTasks,
|
||||
() => {
|
||||
if (!isStillSameIdleWindow()) {
|
||||
clearIdleCompletionHooks()
|
||||
return
|
||||
}
|
||||
queueCompletionBatchNotifications()
|
||||
clearIdleCompletionHooks()
|
||||
}
|
||||
)
|
||||
|
||||
void nextTick(() => {
|
||||
if (!isStillSameIdleWindow()) {
|
||||
clearIdleCompletionHooks()
|
||||
return
|
||||
}
|
||||
|
||||
const hasShownNotifications = queueCompletionBatchNotifications()
|
||||
if (hasShownNotifications) {
|
||||
clearIdleCompletionHooks()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
isQueueActive,
|
||||
(active, prev) => {
|
||||
if (!prev && active) {
|
||||
clearIdleCompletionHooks()
|
||||
lastActiveStartTs.value = Date.now()
|
||||
return
|
||||
}
|
||||
if (prev && !active) {
|
||||
scheduleIdleCompletionBatchNotifications()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
api.removeEventListener('promptQueueing', handlePromptQueueing)
|
||||
api.removeEventListener('promptQueued', handlePromptQueued)
|
||||
clearIdleCompletionHooks()
|
||||
clearDismissTimer()
|
||||
pendingNotifications.value = []
|
||||
activeNotification.value = null
|
||||
lastActiveStartTs.value = null
|
||||
})
|
||||
|
||||
const currentNotification = computed(() => activeNotification.value)
|
||||
|
||||
return {
|
||||
currentNotification
|
||||
}
|
||||
}
|
||||
@@ -52,17 +52,10 @@ export interface ErrorRecoveryStrategy<
|
||||
export function useErrorHandling() {
|
||||
const toast = useToastStore()
|
||||
const toastErrorHandler = (error: unknown) => {
|
||||
const isNetworkError =
|
||||
error instanceof TypeError && error.message === 'Failed to fetch'
|
||||
const message = isNetworkError
|
||||
? t('g.disconnectedFromBackend')
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: t('g.unknownError')
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: message
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,7 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements'
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,9 +96,6 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.user_secrets_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get nodeReplacementsEnabled() {
|
||||
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -155,18 +155,11 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
const getInputImageUrl = (): string | null => {
|
||||
if (!node.value) return null
|
||||
|
||||
let sourceNode = node.value.getInputNode(0)
|
||||
if (!sourceNode) return null
|
||||
const inputNode = node.value.getInputNode(0)
|
||||
|
||||
if (sourceNode.isSubgraphNode()) {
|
||||
const link = node.value.getInputLink(0)
|
||||
if (!link) return null
|
||||
const resolved = sourceNode.resolveSubgraphOutputLink(link.origin_slot)
|
||||
sourceNode = resolved?.outputNode ?? null
|
||||
if (!sourceNode) return null
|
||||
}
|
||||
if (!inputNode) return null
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(sourceNode)
|
||||
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
|
||||
|
||||
if (urls?.length) {
|
||||
return urls[0]
|
||||
@@ -243,6 +236,17 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
height: `${cropHeight.value * scaleFactor.value}px`
|
||||
}))
|
||||
|
||||
const cropImageStyle = computed(() => {
|
||||
if (!imageUrl.value) return {}
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${imageUrl.value})`,
|
||||
backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`,
|
||||
backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`,
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}
|
||||
})
|
||||
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
@@ -558,10 +562,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
|
||||
const initialize = () => {
|
||||
if (nodeId != null) {
|
||||
node.value =
|
||||
app.canvas?.graph?.getNodeById(nodeId) ||
|
||||
app.rootGraph?.getNodeById(nodeId) ||
|
||||
null
|
||||
node.value = app.rootGraph?.getNodeById(nodeId) || null
|
||||
}
|
||||
|
||||
updateImageUrl()
|
||||
@@ -594,6 +595,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
isLockEnabled,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
|
||||
@@ -201,10 +201,11 @@ describe('pasteImageNodes', () => {
|
||||
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const fileList = createDataTransfer([file1, file2]).files
|
||||
|
||||
const result = await pasteImageNodes(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
[file1, file2]
|
||||
fileList
|
||||
)
|
||||
|
||||
expect(createNode).toHaveBeenCalledTimes(2)
|
||||
@@ -216,9 +217,11 @@ describe('pasteImageNodes', () => {
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const fileList = createDataTransfer([]).files
|
||||
|
||||
const result = await pasteImageNodes(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
[]
|
||||
fileList
|
||||
)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function pasteImageNode(
|
||||
|
||||
export async function pasteImageNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: File[]
|
||||
fileList: FileList
|
||||
): Promise<LGraphNode[]> {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ useExtensionService().registerExtension({
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'ImageCropV2') return
|
||||
|
||||
node.hideOutputImages = true
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type {
|
||||
ContextMenuDivElement,
|
||||
IContextMenuOptions,
|
||||
@@ -7,38 +5,6 @@ import type {
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
|
||||
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
|
||||
const ALLOWED_STYLE_PROPS = new Set([
|
||||
'display',
|
||||
'color',
|
||||
'background-color',
|
||||
'padding-left',
|
||||
'border-left'
|
||||
])
|
||||
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
if (data.attrName === 'style') {
|
||||
const sanitizedStyle = data.attrValue
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => {
|
||||
const colonIdx = s.indexOf(':')
|
||||
if (colonIdx === -1) return false
|
||||
const prop = s.slice(0, colonIdx).trim().toLowerCase()
|
||||
return ALLOWED_STYLE_PROPS.has(prop)
|
||||
})
|
||||
.join('; ')
|
||||
data.attrValue = sanitizedStyle
|
||||
}
|
||||
})
|
||||
|
||||
function sanitizeMenuHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ['style']
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Replace this pattern with something more modern.
|
||||
export interface ContextMenu<TValue = unknown> {
|
||||
constructor: new (
|
||||
@@ -157,7 +123,7 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (options.title) {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'litemenu-title'
|
||||
element.textContent = options.title
|
||||
element.innerHTML = options.title
|
||||
root.append(element)
|
||||
}
|
||||
|
||||
@@ -252,18 +218,11 @@ export class ContextMenu<TValue = unknown> {
|
||||
if (value === null) {
|
||||
element.classList.add('separator')
|
||||
} else {
|
||||
const label = name === null ? '' : String(name)
|
||||
const innerHtml = name === null ? '' : String(name)
|
||||
if (typeof value === 'string') {
|
||||
element.textContent = label
|
||||
element.innerHTML = innerHtml
|
||||
} else {
|
||||
// Use innerHTML for content that contains HTML tags, textContent otherwise
|
||||
const hasHtmlContent =
|
||||
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
|
||||
if (hasHtmlContent) {
|
||||
element.innerHTML = sanitizeMenuHTML(value.content!)
|
||||
} else {
|
||||
element.textContent = value?.title ?? label
|
||||
}
|
||||
element.innerHTML = value?.title ?? innerHtml
|
||||
|
||||
if (value.disabled) {
|
||||
disabled = true
|
||||
|
||||
@@ -965,12 +965,8 @@ export class LGraphNode
|
||||
o.widgets_values = []
|
||||
for (const [i, widget] of widgets.entries()) {
|
||||
if (widget.serialize === false) continue
|
||||
const val = widget?.value
|
||||
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
|
||||
o.widgets_values[i] =
|
||||
val != null && typeof val === 'object'
|
||||
? JSON.parse(JSON.stringify(val))
|
||||
: (val ?? null)
|
||||
// @ts-expect-error #595 No-null
|
||||
o.widgets_values[i] = widget ? widget.value : null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -798,7 +798,6 @@
|
||||
"disableSelected": "تعطيل المحدد",
|
||||
"disableThirdParty": "تعطيل الطرف الثالث",
|
||||
"disabling": "جارٍ التعطيل",
|
||||
"disconnectedFromBackend": "تم قطع الاتصال بالخادم الخلفي. يرجى التحقق مما إذا كان الخادم يعمل.",
|
||||
"dismiss": "تجاهل",
|
||||
"download": "تنزيل",
|
||||
"downloadAudio": "تنزيل الصوت",
|
||||
@@ -848,7 +847,6 @@
|
||||
"hideLeftPanel": "إخفاء اللوحة اليسرى",
|
||||
"hideRightPanel": "إخفاء اللوحة اليمنى",
|
||||
"icon": "أيقونة",
|
||||
"imageDoesNotExist": "الصورة غير موجودة",
|
||||
"imageFailedToLoad": "فشل تحميل الصورة",
|
||||
"imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور",
|
||||
"imageUrl": "رابط الصورة",
|
||||
@@ -998,7 +996,6 @@
|
||||
"title": "العنوان",
|
||||
"triggerPhrase": "عبارة التشغيل",
|
||||
"unknownError": "خطأ غير معروف",
|
||||
"unknownFile": "ملف غير معروف",
|
||||
"untitled": "بدون عنوان",
|
||||
"update": "تحديث",
|
||||
"updateAvailable": "تحديث متاح",
|
||||
@@ -1898,25 +1895,6 @@
|
||||
"outputs": "المُخرجات",
|
||||
"type": "النوع"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "بدائل متوافقة",
|
||||
"installMissingNodes": "تثبيت العقد المفقودة",
|
||||
"installationRequired": "التثبيت مطلوب",
|
||||
"instructionMessage": "يجب عليك تثبيت هذه العقد أو استبدالها ببدائل مثبتة لتشغيل سير العمل. العقد المفقودة مميزة باللون {red} على اللوحة. بعض العقد لا يمكن استبدالها ويجب تثبيتها عبر مدير العقد.",
|
||||
"notReplaceable": "التثبيت مطلوب",
|
||||
"openNodeManager": "فتح مدير العقد",
|
||||
"quickFixAvailable": "إصلاح سريع متاح",
|
||||
"redHighlight": "أحمر",
|
||||
"replaceFailed": "فشل في استبدال العقد",
|
||||
"replaceSelected": "استبدال المحدد ({count})",
|
||||
"replaceWarning": "سيؤدي هذا إلى تعديل سير العمل بشكل دائم. احفظ نسخة أولاً إذا لم تكن متأكدًا.",
|
||||
"replaceable": "قابل للاستبدال",
|
||||
"replaced": "تم الاستبدال",
|
||||
"replacedAllNodes": "تم استبدال {count} نوع/أنواع من العقد",
|
||||
"replacedNode": "تم استبدال العقدة: {nodeType}",
|
||||
"selectAll": "تحديد الكل",
|
||||
"skipForNow": "تخطي الآن"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "أدخل الاسم",
|
||||
"saveAsTemplate": "حفظ كقالب"
|
||||
@@ -2000,7 +1978,6 @@
|
||||
"removeJob": "إزالة المهمة",
|
||||
"reportError": "الإبلاغ عن خطأ"
|
||||
},
|
||||
"jobQueueing": "جارٍ إضافة العمل إلى قائمة الانتظار",
|
||||
"toggleJobHistory": "تبديل سجل المهام"
|
||||
},
|
||||
"releaseToast": {
|
||||
@@ -2059,8 +2036,6 @@
|
||||
"pinned": "مثبت",
|
||||
"properties": "الخصائص",
|
||||
"removeFavorite": "إزالة من المفضلة",
|
||||
"resetAllParameters": "إعادة تعيين جميع المعلمات",
|
||||
"resetToDefault": "إعادة التعيين إلى الافتراضي",
|
||||
"settings": "الإعدادات",
|
||||
"showAdvancedInputsButton": "إظهار المدخلات المتقدمة",
|
||||
"showInput": "إظهار المدخل",
|
||||
@@ -2424,20 +2399,14 @@
|
||||
"filterJobs": "تصفية المهام",
|
||||
"inlineTotalLabel": "الإجمالي",
|
||||
"interruptAll": "إيقاف جميع المهام الجارية",
|
||||
"jobCompleted": "اكتمل العمل",
|
||||
"jobFailed": "فشل العمل",
|
||||
"jobQueue": "قائمة المهام",
|
||||
"jobsAddedToQueue": "{count} مهمة أُضيفت إلى قائمة الانتظار | {count} مهام أُضيفت إلى قائمة الانتظار",
|
||||
"jobsCompleted": "{count} مهمة مكتملة | {count} مهام مكتملة",
|
||||
"jobsFailed": "{count} مهمة فشلت | {count} مهام فشلت",
|
||||
"moreOptions": "خيارات إضافية",
|
||||
"noActiveJobs": "لا توجد مهام نشطة",
|
||||
"preview": "معاينة",
|
||||
"queuedJobsLabel": "{count} في الانتظار",
|
||||
"queuedSuffix": "في الانتظار",
|
||||
"running": "قيد التشغيل",
|
||||
"runningJobsLabel": "{count} قيد التشغيل",
|
||||
"runningQueuedSummary": "{running} قيد التشغيل، {queued} في الانتظار",
|
||||
"showAssets": "عرض الأصول",
|
||||
"showAssetsPanel": "عرض لوحة الأصول",
|
||||
"sortBy": "ترتيب حسب",
|
||||
|
||||
@@ -353,6 +353,15 @@
|
||||
"name": "الإشراف",
|
||||
"tooltip": "إعدادات الإشراف"
|
||||
},
|
||||
"moderation_prompt_content_moderation": {
|
||||
"name": "إشراف محتوى التوجيه"
|
||||
},
|
||||
"moderation_visual_input_moderation": {
|
||||
"name": "إشراف الإدخال البصري"
|
||||
},
|
||||
"moderation_visual_output_moderation": {
|
||||
"name": "إشراف الإخراج البصري"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "توجيه سلبي"
|
||||
},
|
||||
@@ -381,56 +390,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaRemoveImageBackground": {
|
||||
"description": "إزالة الخلفية من صورة باستخدام Bria RMBG 2.0.",
|
||||
"display_name": "Bria إزالة خلفية الصورة",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"image": {
|
||||
"name": "صورة"
|
||||
},
|
||||
"moderation": {
|
||||
"name": "الإشراف",
|
||||
"tooltip": "إعدادات الإشراف"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaRemoveVideoBackground": {
|
||||
"description": "إزالة الخلفية من فيديو باستخدام Bria.",
|
||||
"display_name": "Bria إزالة خلفية الفيديو",
|
||||
"inputs": {
|
||||
"background_color": {
|
||||
"name": "لون الخلفية",
|
||||
"tooltip": "لون الخلفية للفيديو الناتج."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"video": {
|
||||
"name": "فيديو"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceFirstLastFrameNode": {
|
||||
"description": "إنشاء فيديو باستخدام المطالبة النصية والإطار الأول والأخير.",
|
||||
"display_name": "تحويل الإطار الأول-الأخير من ByteDance إلى فيديو",
|
||||
@@ -10338,32 +10297,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "يطبق توجيه الانتباه المعياري على النماذج، مما يتيح استخدام المطالبات السلبية على النماذج المقطرة/schnell.",
|
||||
"display_name": "توجيه الانتباه المعياري",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج الذي سيتم تطبيق NAG عليه."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "معامل المزج",
|
||||
"tooltip": "معامل المزج للانتباه المعياري. القيمة 1.0 تعني استبدال كامل، 0.0 تعني عدم وجود تأثير."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "عامل مقياس التوجيه",
|
||||
"tooltip": "عامل مقياس التوجيه. القيم الأعلى تدفع أبعد عن المطالبة السلبية."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "النموذج المعدل مع تفعيل NAG."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "تطبيع الصور",
|
||||
"inputs": {
|
||||
@@ -11738,88 +11671,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "ينتج صورًا باستخدام نماذج Recraft V4 أو V4 Pro.",
|
||||
"display_name": "Recraft V4 تحويل النص إلى صورة",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المستخدم في التوليد."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "الحجم"
|
||||
},
|
||||
"n": {
|
||||
"name": "عدد الصور",
|
||||
"tooltip": "عدد الصور المراد إنشاؤها."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "المطالبة السلبية",
|
||||
"tooltip": "وصف نصي اختياري للعناصر غير المرغوب فيها في الصورة."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "المطالبة",
|
||||
"tooltip": "المطالبة لإنشاء الصورة. الحد الأقصى ١٠٬٠٠٠ حرف."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "عناصر تحكم Recraft",
|
||||
"tooltip": "عناصر تحكم إضافية اختيارية في التوليد عبر عقدة عناصر تحكم Recraft."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "ينتج SVG باستخدام نماذج Recraft V4 أو V4 Pro.",
|
||||
"display_name": "Recraft V4 تحويل النص إلى متجه",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المستخدم في التوليد."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "الحجم"
|
||||
},
|
||||
"n": {
|
||||
"name": "عدد الصور",
|
||||
"tooltip": "عدد الصور المراد إنشاؤها."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "المطالبة السلبية",
|
||||
"tooltip": "وصف نصي اختياري للعناصر غير المرغوب فيها في الصورة."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "المطالبة",
|
||||
"tooltip": "المطالبة لإنشاء الصورة. الحد الأقصى ١٠٬٠٠٠ حرف."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "عناصر تحكم Recraft",
|
||||
"tooltip": "عناصر تحكم إضافية اختيارية في التوليد عبر عقدة عناصر تحكم Recraft."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "ينشئ SVG بشكل متزامن من صورة إدخال.",
|
||||
"display_name": "إعادة صياغة تحويل الصورة إلى متجه",
|
||||
@@ -14248,29 +14099,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tencent3DPartNode": {
|
||||
"description": "تنفيذ التعرف على المكونات وتوليدها تلقائيًا بناءً على هيكل النموذج.",
|
||||
"display_name": "Hunyuan3D: جزء ثلاثي الأبعاد",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "نموذج_ثلاثي_الأبعاد",
|
||||
"tooltip": "نموذج ثلاثي الأبعاد بصيغة FBX. يجب أن يحتوي النموذج على أقل من ٣٠٠٠٠ وجه."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TencentImageToModelNode": {
|
||||
"display_name": "Hunyuan3D: من صورة إلى نموذج (احترافي)",
|
||||
"inputs": {
|
||||
@@ -15954,46 +15782,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "إنشاء فيديو من إطار بداية، إطار نهاية، ونص توجيهي.",
|
||||
"display_name": "توليد فيديو من إطار البداية/النهاية باستخدام Vidu Q3",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "إطار النهاية"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "إطار البداية"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المستخدم لتوليد الفيديو."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "الصوت"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص التوجيهي",
|
||||
"tooltip": "وصف النص التوجيهي (بحد أقصى ٢٠٠٠ حرف)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "إنشاء فيديو من نص.",
|
||||
"display_name": "توليد فيديو من نص Vidu Q3",
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"unknownFile": "Unknown file",
|
||||
"reconnecting": "Reconnecting",
|
||||
"reconnected": "Reconnected",
|
||||
"disconnectedFromBackend": "Disconnected from backend. Check if the server is running.",
|
||||
"delete": "Delete",
|
||||
"rename": "Rename",
|
||||
"save": "Save",
|
||||
@@ -814,19 +813,13 @@
|
||||
"activeJobs": "{count} active job | {count} active jobs",
|
||||
"activeJobsShort": "{count} active | {count} active",
|
||||
"activeJobsSuffix": "active jobs",
|
||||
"runningJobsLabel": "{count} running",
|
||||
"queuedJobsLabel": "{count} queued",
|
||||
"runningQueuedSummary": "{running}, {queued}",
|
||||
"jobQueue": "Job Queue",
|
||||
"expandCollapsedQueue": "Expand job queue",
|
||||
"viewJobHistory": "View active jobs (right-click to clear queue)",
|
||||
"noActiveJobs": "No active jobs",
|
||||
"stubClipTextEncode": "CLIP Text Encode:",
|
||||
"jobCompleted": "Job completed",
|
||||
"jobFailed": "Job failed",
|
||||
"jobsCompleted": "{count} job completed | {count} jobs completed",
|
||||
"jobsFailed": "{count} job failed | {count} jobs failed",
|
||||
"jobsAddedToQueue": "{count} job added to queue | {count} jobs added to queue",
|
||||
"cancelJobTooltip": "Cancel job",
|
||||
"clearQueueTooltip": "Clear queue",
|
||||
"clearHistoryDialogTitle": "Clear your job queue history?",
|
||||
@@ -1120,7 +1113,6 @@
|
||||
"initializingAlmostReady": "Initializing - Almost ready",
|
||||
"inQueue": "In queue...",
|
||||
"jobAddedToQueue": "Job added to queue",
|
||||
"jobQueueing": "Job queueing",
|
||||
"completedIn": "Finished in {duration}",
|
||||
"jobMenu": {
|
||||
"openAsWorkflowNewTab": "Open as workflow in new tab",
|
||||
@@ -1567,7 +1559,6 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"model_specific": "model_specific",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"": "",
|
||||
"OpenAI": "OpenAI",
|
||||
"Sora": "Sora",
|
||||
"cond pair": "cond pair",
|
||||
@@ -1592,6 +1583,7 @@
|
||||
"Tripo": "Tripo",
|
||||
"Veo": "Veo",
|
||||
"Vidu": "Vidu",
|
||||
"": "",
|
||||
"camera": "camera",
|
||||
"Wan": "Wan",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
@@ -2050,7 +2042,7 @@
|
||||
"howManyCredits": "How many credits would you like to add?",
|
||||
"usdAmount": "${amount}",
|
||||
"videosEstimate": "~{count} videos*",
|
||||
"templateNote": "*Generated with Wan 2.2 Image-to-Video template",
|
||||
"templateNote": "*Generated with Wan Fun Control template",
|
||||
"buy": "Buy",
|
||||
"purchaseSuccess": "Credits added successfully!",
|
||||
"purchaseError": "Purchase Failed",
|
||||
@@ -2155,7 +2147,7 @@
|
||||
"messageSupport": "Message support",
|
||||
"invoiceHistory": "Invoice history",
|
||||
"benefits": {
|
||||
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
|
||||
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
|
||||
"benefit2": "Up to 30 min runtime per job"
|
||||
},
|
||||
"yearlyDiscount": "20% DISCOUNT",
|
||||
@@ -2213,7 +2205,7 @@
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan 2.2 Image-to-Video template",
|
||||
"videoEstimateHelp": "More details on this template",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan 2.2 Image-to-Video template using default settings (5 sec, 16fps, 640x640, 4-step sampling).",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan 2.2 Image-to-Video template using default settings (5 seconds, 640x640, 16fps, 4-step sampling).",
|
||||
"videoEstimateTryTemplate": "Try this template",
|
||||
"videoTemplateBasedCredits": "Videos generated with Wan 2.2 Image to Video",
|
||||
"upgradePlan": "Upgrade Plan",
|
||||
@@ -2817,10 +2809,9 @@
|
||||
"insertAllAssetsAsNodes": "Insert all assets as nodes",
|
||||
"openWorkflowAll": "Open all workflows",
|
||||
"exportWorkflowAll": "Export all workflows",
|
||||
"downloadStarted": "Downloading {count} file... | Downloading {count} files...",
|
||||
"downloadsStarted": "Started downloading {count} file | Started downloading {count} files",
|
||||
"exportStarted": "Preparing ZIP export for {count} file | Preparing ZIP export for {count} files",
|
||||
"assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully",
|
||||
"downloadStarted": "Downloading {count} files...",
|
||||
"downloadsStarted": "Started downloading {count} file(s)",
|
||||
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
|
||||
"failedToDeleteAssets": "Failed to delete selected assets",
|
||||
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
|
||||
"nodesAddedToWorkflow": "{count} node(s) added to workflow",
|
||||
@@ -2904,25 +2895,6 @@
|
||||
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
|
||||
}
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"quickFixAvailable": "Quick Fix Available",
|
||||
"installationRequired": "Installation Required",
|
||||
"compatibleAlternatives": "Compatible Alternatives",
|
||||
"replaceable": "Replaceable",
|
||||
"replaced": "Replaced",
|
||||
"notReplaceable": "Install Required",
|
||||
"selectAll": "Select All",
|
||||
"replaceSelected": "Replace Selected ({count})",
|
||||
"replacedNode": "Replaced node: {nodeType}",
|
||||
"replacedAllNodes": "Replaced {count} node type(s)",
|
||||
"replaceFailed": "Failed to replace nodes",
|
||||
"instructionMessage": "You must install these nodes or replace them with installed alternatives to run the workflow. Missing nodes are highlighted in {red} on the canvas. Some nodes cannot be swapped and must be installed via Node Manager.",
|
||||
"redHighlight": "red",
|
||||
"openNodeManager": "Open Node Manager",
|
||||
"skipForNow": "Skip for Now",
|
||||
"installMissingNodes": "Install Missing Nodes",
|
||||
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
|
||||
},
|
||||
"rightSidePanel": {
|
||||
"togglePanel": "Toggle properties panel",
|
||||
"noSelection": "Select a node to see its properties and info.",
|
||||
@@ -2977,24 +2949,7 @@
|
||||
"nodesNoneDesc": "NO NODES",
|
||||
"fallbackGroupTitle": "Group",
|
||||
"fallbackNodeTitle": "Node",
|
||||
"hideAdvancedInputsButton": "Hide advanced inputs",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"seeError": "See Error",
|
||||
"promptErrors": {
|
||||
"prompt_no_outputs": {
|
||||
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
|
||||
},
|
||||
"no_prompt": {
|
||||
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
|
||||
}
|
||||
},
|
||||
"errorHelp": "For more help, {github} or {support}",
|
||||
"errorHelpGithub": "submit a GitHub issue",
|
||||
"errorHelpSupport": "contact our support",
|
||||
"resetToDefault": "Reset to default",
|
||||
"resetAllParameters": "Reset all parameters"
|
||||
"hideAdvancedInputsButton": "Hide advanced inputs"
|
||||
},
|
||||
"help": {
|
||||
"recentReleases": "Recent releases",
|
||||
@@ -3016,20 +2971,6 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
},
|
||||
"exportToast": {
|
||||
"exportingAssets": "Exporting Assets",
|
||||
"preparingExport": "Preparing export...",
|
||||
"exportError": "Export failed",
|
||||
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
|
||||
"allExportsCompleted": "All exports completed",
|
||||
"noExportsInQueue": "No {filter} exports in queue",
|
||||
"exportStarted": "Preparing ZIP download...",
|
||||
"exportCompleted": "ZIP download ready",
|
||||
"exportFailedSingle": "Failed to create ZIP export",
|
||||
"downloadExport": "Download export",
|
||||
"downloadFailed": "Failed to download \"{name}\"",
|
||||
"retryDownload": "Retry download"
|
||||
},
|
||||
"workspace": {
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
|
||||
@@ -369,6 +369,15 @@
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"moderation_prompt_content_moderation": {
|
||||
"name": "prompt_content_moderation"
|
||||
},
|
||||
"moderation_visual_input_moderation": {
|
||||
"name": "visual_input_moderation"
|
||||
},
|
||||
"moderation_visual_output_moderation": {
|
||||
"name": "visual_output_moderation"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -381,56 +390,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaRemoveImageBackground": {
|
||||
"display_name": "Bria Remove Image Background",
|
||||
"description": "Remove the background from an image using Bria RMBG 2.0.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"moderation": {
|
||||
"name": "moderation",
|
||||
"tooltip": "Moderation settings"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaRemoveVideoBackground": {
|
||||
"display_name": "Bria Remove Video Background",
|
||||
"description": "Remove the background from a video using Bria. ",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video"
|
||||
},
|
||||
"background_color": {
|
||||
"name": "background_color",
|
||||
"tooltip": "Background color for the output video."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceFirstLastFrameNode": {
|
||||
"display_name": "ByteDance First-Last-Frame to Video",
|
||||
"description": "Generate video using prompt and first and last frames.",
|
||||
@@ -10399,32 +10358,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"display_name": "Normalized Attention Guidance",
|
||||
"description": "Applies Normalized Attention Guidance to models, enabling negative prompts on distilled/schnell models.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to apply NAG to."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "nag_scale",
|
||||
"tooltip": "The guidance scale factor. Higher values push further from the negative prompt."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "Blending factor for the normalized attention. 1.0 is full replacement, 0.0 is no effect."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "The patched model with NAG enabled."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "Normalize Images",
|
||||
"inputs": {
|
||||
@@ -11673,7 +11606,7 @@
|
||||
},
|
||||
"RecraftStyleV3InfiniteStyleLibrary": {
|
||||
"display_name": "Recraft Style - Infinite Style Library",
|
||||
"description": "Choose style based on preexisting UUID from Recraft's Infinite Style Library.",
|
||||
"description": "Select style based on preexisting UUID from Recraft's Infinite Style Library.",
|
||||
"inputs": {
|
||||
"style_id": {
|
||||
"name": "style_id",
|
||||
@@ -11799,88 +11732,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"display_name": "Recraft V4 Text to Image",
|
||||
"description": "Generates images using Recraft V4 or V4 Pro models.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt for the image generation. Maximum 10,000 characters."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "An optional text description of undesired elements on an image."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to use for generation."
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "The number of images to generate."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"display_name": "Recraft V4 Text to Vector",
|
||||
"description": "Generates SVG using Recraft V4 or V4 Pro models.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt for the image generation. Maximum 10,000 characters."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "An optional text description of undesired elements on an image."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to use for generation."
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "The number of images to generate."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Optional additional controls over the generation via the Recraft Controls node."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_size": {
|
||||
"name": "size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"display_name": "Recraft Vectorize Image",
|
||||
"description": "Generates SVG synchronously from an input image.",
|
||||
@@ -14335,31 +14186,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tencent3DPartNode": {
|
||||
"display_name": "Hunyuan3D: 3D Part",
|
||||
"description": "Automatically perform component identification and generation based on the model structure.",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "3D model in FBX format. Model should have less than 30000 faces."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TencentImageToModelNode": {
|
||||
"display_name": "Hunyuan3D: Image(s) to Model",
|
||||
"display_name": "Hunyuan3D: Image(s) to Model (Pro)",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
@@ -14410,7 +14238,7 @@
|
||||
}
|
||||
},
|
||||
"TencentTextToModelNode": {
|
||||
"display_name": "Hunyuan3D: Text to Model",
|
||||
"display_name": "Hunyuan3D: Text to Model (Pro)",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
@@ -14899,7 +14727,7 @@
|
||||
},
|
||||
"offloading": {
|
||||
"name": "offloading",
|
||||
"tooltip": "Offload the Model to RAM. Requires Bypass Mode."
|
||||
"tooltip": "Depth level for gradient checkpointing."
|
||||
},
|
||||
"existing_lora": {
|
||||
"name": "existing_lora",
|
||||
@@ -16092,46 +15920,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"display_name": "Vidu Q3 Start/End Frame-to-Video Generation",
|
||||
"description": "Generate a video from a start frame, an end frame, and a prompt.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Model to use for video generation."
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "first_frame"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "end_frame"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt description (max 2000 characters)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"display_name": "Vidu Q3 Text-to-Video Generation",
|
||||
"description": "Generate video from a text prompt.",
|
||||
|
||||
@@ -285,8 +285,8 @@
|
||||
"name": "Show API node pricing badge"
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "Enable node replacement suggestions",
|
||||
"tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements."
|
||||
"name": "Enable automatic node replacement",
|
||||
"tooltip": "When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists."
|
||||
},
|
||||
"Comfy_NodeSearchBoxImpl": {
|
||||
"name": "Node search box implementation",
|
||||
@@ -350,10 +350,6 @@
|
||||
"name": "Batch count limit",
|
||||
"tooltip": "The maximum number of tasks added to the queue at one button click"
|
||||
},
|
||||
"Comfy_RightSidePanel_ShowErrorsTab": {
|
||||
"name": "Show errors tab in side panel",
|
||||
"tooltip": "When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance."
|
||||
},
|
||||
"Comfy_Sidebar_Location": {
|
||||
"name": "Sidebar location",
|
||||
"options": {
|
||||
|
||||
@@ -798,7 +798,6 @@
|
||||
"disableSelected": "Deshabilitar seleccionados",
|
||||
"disableThirdParty": "Deshabilitar terceros",
|
||||
"disabling": "Deshabilitando",
|
||||
"disconnectedFromBackend": "Desconectado del backend. Verifica si el servidor está en funcionamiento.",
|
||||
"dismiss": "Descartar",
|
||||
"download": "Descargar",
|
||||
"downloadAudio": "Descargar audio",
|
||||
@@ -848,7 +847,6 @@
|
||||
"hideLeftPanel": "Ocultar panel izquierdo",
|
||||
"hideRightPanel": "Ocultar panel derecho",
|
||||
"icon": "Icono",
|
||||
"imageDoesNotExist": "La imagen no existe",
|
||||
"imageFailedToLoad": "Falló la carga de la imagen",
|
||||
"imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes",
|
||||
"imageUrl": "URL de la imagen",
|
||||
@@ -998,7 +996,6 @@
|
||||
"title": "Título",
|
||||
"triggerPhrase": "Frase de activación",
|
||||
"unknownError": "Error desconocido",
|
||||
"unknownFile": "Archivo desconocido",
|
||||
"untitled": "Sin título",
|
||||
"update": "Actualizar",
|
||||
"updateAvailable": "Actualización Disponible",
|
||||
@@ -1898,25 +1895,6 @@
|
||||
"outputs": "Salidas",
|
||||
"type": "Tipo"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "Alternativas compatibles",
|
||||
"installMissingNodes": "Instalar nodos faltantes",
|
||||
"installationRequired": "Instalación requerida",
|
||||
"instructionMessage": "Debes instalar estos nodos o reemplazarlos por alternativas instaladas para ejecutar el flujo de trabajo. Los nodos faltantes están resaltados en {red} en el lienzo. Algunos nodos no se pueden intercambiar y deben instalarse mediante el Administrador de Nodos.",
|
||||
"notReplaceable": "Instalación requerida",
|
||||
"openNodeManager": "Abrir Administrador de Nodos",
|
||||
"quickFixAvailable": "Solución rápida disponible",
|
||||
"redHighlight": "rojo",
|
||||
"replaceFailed": "Error al reemplazar nodos",
|
||||
"replaceSelected": "Reemplazar seleccionados ({count})",
|
||||
"replaceWarning": "Esto modificará permanentemente el flujo de trabajo. Guarda una copia primero si no estás seguro.",
|
||||
"replaceable": "Reemplazable",
|
||||
"replaced": "Reemplazado",
|
||||
"replacedAllNodes": "Reemplazados {count} tipo(s) de nodo",
|
||||
"replacedNode": "Nodo reemplazado: {nodeType}",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"skipForNow": "Omitir por ahora"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Introduzca el nombre",
|
||||
"saveAsTemplate": "Guardar como plantilla"
|
||||
@@ -2000,7 +1978,6 @@
|
||||
"removeJob": "Quitar trabajo",
|
||||
"reportError": "Reportar error"
|
||||
},
|
||||
"jobQueueing": "Encolando trabajo",
|
||||
"toggleJobHistory": "Alternar historial de trabajos"
|
||||
},
|
||||
"releaseToast": {
|
||||
@@ -2059,8 +2036,6 @@
|
||||
"pinned": "Fijado",
|
||||
"properties": "Propiedades",
|
||||
"removeFavorite": "Quitar de favoritos",
|
||||
"resetAllParameters": "Restablecer todos los parámetros",
|
||||
"resetToDefault": "Restablecer a los valores predeterminados",
|
||||
"settings": "Configuración",
|
||||
"showAdvancedInputsButton": "Mostrar entradas avanzadas",
|
||||
"showInput": "Mostrar entrada",
|
||||
@@ -2424,20 +2399,14 @@
|
||||
"filterJobs": "Filtrar trabajos",
|
||||
"inlineTotalLabel": "Total",
|
||||
"interruptAll": "Interrumpir todos los trabajos en ejecución",
|
||||
"jobCompleted": "Trabajo completado",
|
||||
"jobFailed": "Trabajo fallido",
|
||||
"jobQueue": "Cola de trabajos",
|
||||
"jobsAddedToQueue": "{count} trabajo añadido a la cola | {count} trabajos añadidos a la cola",
|
||||
"jobsCompleted": "{count} trabajo completado | {count} trabajos completados",
|
||||
"jobsFailed": "{count} trabajo fallido | {count} trabajos fallidos",
|
||||
"moreOptions": "Más opciones",
|
||||
"noActiveJobs": "No hay trabajos activos",
|
||||
"preview": "Vista previa",
|
||||
"queuedJobsLabel": "{count} en cola",
|
||||
"queuedSuffix": "en cola",
|
||||
"running": "en ejecución",
|
||||
"runningJobsLabel": "{count} en ejecución",
|
||||
"runningQueuedSummary": "{running}, {queued}",
|
||||
"showAssets": "Mostrar recursos",
|
||||
"showAssetsPanel": "Mostrar panel de recursos",
|
||||
"sortBy": "Ordenar por",
|
||||
|
||||
@@ -353,6 +353,15 @@
|
||||
"name": "moderación",
|
||||
"tooltip": "Configuración de moderación"
|
||||
},
|
||||
"moderation_prompt_content_moderation": {
|
||||
"name": "moderación_de_contenido_de_instrucción"
|
||||
},
|
||||
"moderation_visual_input_moderation": {
|
||||
"name": "moderación_visual_de_entrada"
|
||||
},
|
||||
"moderation_visual_output_moderation": {
|
||||
"name": "moderación_visual_de_salida"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "instrucción_negativa"
|
||||
},
|
||||
@@ -381,56 +390,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaRemoveImageBackground": {
|
||||
"description": "Quita el fondo de una imagen usando Bria RMBG 2.0.",
|
||||
"display_name": "Bria Quitar Fondo de Imagen",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"image": {
|
||||
"name": "imagen"
|
||||
},
|
||||
"moderation": {
|
||||
"name": "moderación",
|
||||
"tooltip": "Configuración de moderación"
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaRemoveVideoBackground": {
|
||||
"description": "Quita el fondo de un video usando Bria.",
|
||||
"display_name": "Bria Quitar Fondo de Video",
|
||||
"inputs": {
|
||||
"background_color": {
|
||||
"name": "color de fondo",
|
||||
"tooltip": "Color de fondo para el video de salida."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"video": {
|
||||
"name": "video"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceFirstLastFrameNode": {
|
||||
"description": "Generar video usando prompt y primer y último fotograma.",
|
||||
"display_name": "ByteDance Primer-Último-Fotograma a Video",
|
||||
@@ -10338,32 +10297,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NAGuidance": {
|
||||
"description": "Aplica la Guía de Atención Normalizada a los modelos, permitiendo prompts negativos en modelos distilled/schnell.",
|
||||
"display_name": "Guía de Atención Normalizada",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "El modelo al que se aplicará NAG."
|
||||
},
|
||||
"nag_alpha": {
|
||||
"name": "nag_alpha",
|
||||
"tooltip": "Factor de mezcla para la atención normalizada. 1.0 es reemplazo total, 0.0 sin efecto."
|
||||
},
|
||||
"nag_scale": {
|
||||
"name": "escala_nag",
|
||||
"tooltip": "El factor de escala de la guía. Valores más altos alejan más del prompt negativo."
|
||||
},
|
||||
"nag_tau": {
|
||||
"name": "nag_tau"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "El modelo modificado con NAG habilitado."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NormalizeImages": {
|
||||
"display_name": "Normalizar Imágenes",
|
||||
"inputs": {
|
||||
@@ -11738,88 +11671,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToImageNode": {
|
||||
"description": "Genera imágenes usando los modelos Recraft V4 o V4 Pro.",
|
||||
"display_name": "Recraft V4 Texto a Imagen",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "El modelo a utilizar para la generación."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "tamaño"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "El número de imágenes a generar."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "prompt_negativo",
|
||||
"tooltip": "Una descripción opcional en texto de los elementos no deseados en una imagen."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt para la generación de la imagen. Máximo 10,000 caracteres."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Controles adicionales opcionales sobre la generación a través del nodo Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales no son deterministas independientemente de la semilla."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftV4TextToVectorNode": {
|
||||
"description": "Genera SVG usando los modelos Recraft V4 o V4 Pro.",
|
||||
"display_name": "Recraft V4 Texto a Vector",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "El modelo a utilizar para la generación."
|
||||
},
|
||||
"model_size": {
|
||||
"name": "tamaño"
|
||||
},
|
||||
"n": {
|
||||
"name": "n",
|
||||
"tooltip": "El número de imágenes a generar."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "prompt_negativo",
|
||||
"tooltip": "Una descripción opcional en texto de los elementos no deseados en una imagen."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt para la generación de la imagen. Máximo 10,000 caracteres."
|
||||
},
|
||||
"recraft_controls": {
|
||||
"name": "recraft_controls",
|
||||
"tooltip": "Controles adicionales opcionales sobre la generación a través del nodo Recraft Controls."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales no son deterministas independientemente de la semilla."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RecraftVectorizeImageNode": {
|
||||
"description": "Genera SVG de forma sincrónica a partir de una imagen de entrada.",
|
||||
"display_name": "Recraft Vectorizar Imagen",
|
||||
@@ -14248,29 +14099,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tencent3DPartNode": {
|
||||
"description": "Realiza automáticamente la identificación y generación de componentes según la estructura del modelo.",
|
||||
"display_name": "Hunyuan3D: Parte 3D",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "controlar después de generar"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "modelo_3d",
|
||||
"tooltip": "Modelo 3D en formato FBX. El modelo debe tener menos de 30,000 caras."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla",
|
||||
"tooltip": "La semilla controla si el nodo debe volver a ejecutarse; los resultados son no deterministas independientemente de la semilla."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TencentImageToModelNode": {
|
||||
"display_name": "Hunyuan3D: Imagen(es) a Modelo (Pro)",
|
||||
"inputs": {
|
||||
@@ -15954,46 +15782,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3StartEndToVideoNode": {
|
||||
"description": "Genera un video a partir de un fotograma inicial, un fotograma final y un prompt.",
|
||||
"display_name": "Generación de video de inicio/fin de Vidu Q3",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control después de generar"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "fotograma final"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "fotograma inicial"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "Modelo a utilizar para la generación de video."
|
||||
},
|
||||
"model_audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"model_duration": {
|
||||
"name": "duración"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolución"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Descripción del prompt (máximo 2000 caracteres)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "semilla"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu3TextToVideoNode": {
|
||||
"description": "Genera un video a partir de un prompt de texto.",
|
||||
"display_name": "Generación de video de texto a video Vidu Q3",
|
||||
|
||||
@@ -798,7 +798,6 @@
|
||||
"disableSelected": "غیرفعالسازی انتخابشدهها",
|
||||
"disableThirdParty": "غیرفعالسازی شخص ثالث",
|
||||
"disabling": "در حال غیرفعالسازی {id}",
|
||||
"disconnectedFromBackend": "ارتباط با بکاند قطع شد. لطفاً بررسی کنید که سرور در حال اجرا باشد.",
|
||||
"dismiss": "رد کردن",
|
||||
"download": "دانلود",
|
||||
"downloadAudio": "دانلود صوت",
|
||||
@@ -848,7 +847,6 @@
|
||||
"hideLeftPanel": "پنهان کردن پنل چپ",
|
||||
"hideRightPanel": "پنهان کردن پنل راست",
|
||||
"icon": "آیکون",
|
||||
"imageDoesNotExist": "تصویر وجود ندارد",
|
||||
"imageFailedToLoad": "بارگذاری تصویر ناموفق بود",
|
||||
"imagePreview": "پیشنمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهتدار استفاده کنید",
|
||||
"imageUrl": "آدرس تصویر",
|
||||
@@ -998,7 +996,6 @@
|
||||
"title": "عنوان",
|
||||
"triggerPhrase": "عبارت trigger",
|
||||
"unknownError": "خطای ناشناخته",
|
||||
"unknownFile": "فایل ناشناخته",
|
||||
"untitled": "بدون عنوان",
|
||||
"update": "بهروزرسانی",
|
||||
"updateAvailable": "بهروزرسانی موجود است",
|
||||
@@ -1898,25 +1895,6 @@
|
||||
"outputs": "خروجیها",
|
||||
"type": "نوع"
|
||||
},
|
||||
"nodeReplacement": {
|
||||
"compatibleAlternatives": "گزینههای سازگار",
|
||||
"installMissingNodes": "نصب نودهای مفقود",
|
||||
"installationRequired": "نصب مورد نیاز است",
|
||||
"instructionMessage": "برای اجرای workflow باید این نودها را نصب یا با گزینههای نصبشده جایگزین کنید. نودهای مفقود با رنگ {red} روی بوم مشخص شدهاند. برخی نودها قابل تعویض نیستند و باید از طریق Node Manager نصب شوند.",
|
||||
"notReplaceable": "نیاز به نصب",
|
||||
"openNodeManager": "باز کردن Node Manager",
|
||||
"quickFixAvailable": "رفع سریع در دسترس است",
|
||||
"redHighlight": "قرمز",
|
||||
"replaceFailed": "جایگزینی نودها ناموفق بود",
|
||||
"replaceSelected": "جایگزینی انتخابشدهها ({count})",
|
||||
"replaceWarning": "این کار workflow را به طور دائمی تغییر میدهد. اگر مطمئن نیستید، ابتدا یک نسخه ذخیره کنید.",
|
||||
"replaceable": "قابل جایگزینی",
|
||||
"replaced": "جایگزین شد",
|
||||
"replacedAllNodes": "{count} نوع نود جایگزین شد",
|
||||
"replacedNode": "نود جایگزین شد: {nodeType}",
|
||||
"selectAll": "انتخاب همه",
|
||||
"skipForNow": "فعلاً رد شود"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "نام را وارد کنید",
|
||||
"saveAsTemplate": "ذخیره به عنوان قالب"
|
||||
@@ -2000,7 +1978,6 @@
|
||||
"removeJob": "حذف کار",
|
||||
"reportError": "گزارش خطا"
|
||||
},
|
||||
"jobQueueing": "در حال صفبندی کار",
|
||||
"toggleJobHistory": "نمایش/مخفیسازی تاریخچه کارها"
|
||||
},
|
||||
"releaseToast": {
|
||||
@@ -2059,8 +2036,6 @@
|
||||
"pinned": "سنجاق شده",
|
||||
"properties": "ویژگیها",
|
||||
"removeFavorite": "حذف از علاقهمندیها",
|
||||
"resetAllParameters": "بازنشانی همه پارامترها",
|
||||
"resetToDefault": "بازنشانی به پیشفرض",
|
||||
"settings": "تنظیمات",
|
||||
"showAdvancedInputsButton": "نمایش ورودیهای پیشرفته",
|
||||
"showInput": "نمایش ورودی",
|
||||
@@ -2435,20 +2410,14 @@
|
||||
"filterJobs": "فیلتر کارها",
|
||||
"inlineTotalLabel": "کل",
|
||||
"interruptAll": "توقف همه کارهای در حال اجرا",
|
||||
"jobCompleted": "کار با موفقیت انجام شد",
|
||||
"jobFailed": "کار ناموفق بود",
|
||||
"jobQueue": "صف کار",
|
||||
"jobsAddedToQueue": "{count} کار به صف افزوده شد | {count} کار به صف افزوده شدند",
|
||||
"jobsCompleted": "{count} کار تکمیل شد",
|
||||
"jobsFailed": "{count} کار ناموفق بود",
|
||||
"moreOptions": "گزینههای بیشتر",
|
||||
"noActiveJobs": "کار فعالی وجود ندارد",
|
||||
"preview": "پیشنمایش",
|
||||
"queuedJobsLabel": "{count} در صف",
|
||||
"queuedSuffix": "در صف",
|
||||
"running": "در حال اجرا",
|
||||
"runningJobsLabel": "{count} در حال اجرا",
|
||||
"runningQueuedSummary": "{running}، {queued}",
|
||||
"showAssets": "نمایش داراییها",
|
||||
"showAssetsPanel": "نمایش پنل داراییها",
|
||||
"sortBy": "مرتبسازی بر اساس",
|
||||
|
||||