Compare commits

..

2 Commits

Author SHA1 Message Date
Terry Jia
1583c15637 Merge branch 'main' into feat/gradient-slider 2026-02-14 03:56:39 -05:00
Terry Jia
233f240503 feat: add GradientSlider component 2026-02-13 19:47:14 -05:00
193 changed files with 1856 additions and 10557 deletions

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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})`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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: []
}
}

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ import type {
BillingActions,
BillingState,
SubscriptionInfo
} from '../../../composables/billing/types'
} from './types'
/**
* Adapter for workspace-scoped billing via /billing/* endpoints.

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,7 +96,7 @@ export async function pasteImageNode(
export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: File[]
fileList: FileList
): Promise<LGraphNode[]> {
const nodes: LGraphNode[] = []

View File

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

View File

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

View File

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

View File

@@ -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": "ترتيب حسب",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "مرتب‌سازی بر اساس",

Some files were not shown because too many files have changed in this diff Show More