Compare commits
40 Commits
devtools/r
...
cloud/1.32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71d6c9ff94 | ||
|
|
b3d6a49328 | ||
|
|
0ea066599d | ||
|
|
20b29f0301 | ||
|
|
ac4c553a46 | ||
|
|
6516e0bdbf | ||
|
|
fbf5e6db21 | ||
|
|
93b71b2b64 | ||
|
|
a10f9fa30b | ||
|
|
427bc312e4 | ||
|
|
b63df5efaf | ||
|
|
f599b89252 | ||
|
|
19b673cbf5 | ||
|
|
50c353ebb6 | ||
|
|
75e448085b | ||
|
|
3d2be8f259 | ||
|
|
15ab78370e | ||
|
|
00e27700e4 | ||
|
|
5d279b680e | ||
|
|
4d7369b97d | ||
|
|
66509b81c4 | ||
|
|
e4ef961876 | ||
|
|
7e7d0e84e5 | ||
|
|
cca12d8dc5 | ||
|
|
ccccd0ff0d | ||
|
|
5af9b842ac | ||
|
|
fe9c960d24 | ||
|
|
b2f2144d51 | ||
|
|
c4f7686575 | ||
|
|
14d94da52b | ||
|
|
a521066b25 | ||
|
|
ada0993572 | ||
|
|
e42715086e | ||
|
|
92968f3f9b | ||
|
|
73692464ef | ||
|
|
61b3ca046a | ||
|
|
f8912ebaf4 | ||
|
|
80b87c1277 | ||
|
|
00fa9b691b | ||
|
|
0cff8eb357 |
1
.github/workflows/release-version-bump.yaml
vendored
@@ -59,7 +59,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
|
||||
@@ -85,11 +85,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
InstallStage,
|
||||
type InstallStageInfo,
|
||||
type InstallStageName,
|
||||
ProgressStatus
|
||||
import { InstallStage, ProgressStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import type {
|
||||
InstallStageInfo,
|
||||
InstallStageName
|
||||
} from '@comfyorg/comfyui-electron-types'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
@@ -564,7 +564,7 @@ export class ComfyPage {
|
||||
async dragAndDrop(source: Position, target: Position) {
|
||||
await this.page.mouse.move(source.x, source.y)
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(target.x, target.y)
|
||||
await this.page.mouse.move(target.x, target.y, { steps: 100 })
|
||||
await this.page.mouse.up()
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ export class VueNodeHelpers {
|
||||
* Select a specific Vue node by ID
|
||||
*/
|
||||
async selectNode(nodeId: string): Promise<void> {
|
||||
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
|
||||
await this.page
|
||||
.locator(`[data-node-id="${nodeId}"] .lg-node-header`)
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,11 +79,13 @@ export class VueNodeHelpers {
|
||||
// Select first node normally
|
||||
await this.selectNode(nodeIds[0])
|
||||
|
||||
// Add additional nodes with Ctrl+click
|
||||
// Add additional nodes with Ctrl+click on header
|
||||
for (let i = 1; i < nodeIds.length; i++) {
|
||||
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
await this.page
|
||||
.locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
|
||||
.click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
// Helper function to pan canvas and select node
|
||||
@@ -516,6 +517,7 @@ This is English documentation.
|
||||
)
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select KSampler first
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
@@ -6,6 +6,7 @@ import {
|
||||
test.describe('Vue Nodes Zoom', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 143 KiB |
@@ -1,48 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(comboboxesInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 81 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.32.5",
|
||||
"version": "1.32.6",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
|
||||
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
|
||||
|
||||
--color-interface-panel-job-progress-primary: var(--color-azure-300);
|
||||
--color-interface-panel-job-progress-secondary: var(--color-alpha-azure-600-30);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
|
||||
@@ -265,12 +268,13 @@
|
||||
--palette-interface-button-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 82%, var(--contrast-mix-color));
|
||||
|
||||
--modal-card-background: var(--secondary-background);
|
||||
--modal-card-background-hovered: var(--secondary-background-hover);
|
||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
||||
--modal-card-tag-background: var(--color-smoke-200);
|
||||
--modal-card-tag-background: var(--color-smoke-400);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-white);
|
||||
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
@@ -330,6 +334,12 @@
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-azure-600);
|
||||
|
||||
/* Queue progress (dark theme) */
|
||||
--color-interface-panel-job-progress-primary: var(--color-cobalt-800);
|
||||
--color-interface-panel-job-progress-secondary: var(
|
||||
--color-alpha-azure-600-30
|
||||
);
|
||||
|
||||
--text-secondary: var(--color-slate-100);
|
||||
--text-primary: var(--color-white);
|
||||
|
||||
@@ -368,9 +378,11 @@
|
||||
--component-node-widget-background-highlighted: var(--color-graphite-400);
|
||||
|
||||
--modal-card-background: var(--secondary-background);
|
||||
--modal-card-background-hovered: var(--secondary-background-hover);
|
||||
--modal-card-border-highlighted: var(--color-ash-400);
|
||||
--modal-card-button-surface: var(--color-charcoal-300);
|
||||
--modal-card-placeholder-background: var(--secondary-background);
|
||||
--modal-card-tag-background: var(--color-charcoal-200);
|
||||
--modal-card-tag-background: var(--color-ash-800);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-charcoal-600);
|
||||
|
||||
@@ -386,12 +398,14 @@
|
||||
--color-subscription-button-gradient: var(--subscription-button-gradient);
|
||||
|
||||
--color-modal-card-background: var(--modal-card-background);
|
||||
--color-modal-card-background-hovered: var(--modal-card-background-hovered);
|
||||
--color-modal-card-border-highlighted: var(--modal-card-border-highlighted);
|
||||
--color-modal-card-button-surface: var(--modal-card-button-surface);
|
||||
--color-modal-card-placeholder-background: var(--modal-card-placeholder-background);
|
||||
--color-modal-card-tag-background: var(--modal-card-tag-background);
|
||||
--color-modal-card-tag-foreground: var(--modal-card-tag-foreground);
|
||||
--color-modal-panel-background: var(--modal-panel-background);
|
||||
|
||||
|
||||
--color-dialog-surface: var(--dialog-surface);
|
||||
--color-interface-menu-component-surface-hovered: var(
|
||||
--interface-menu-component-surface-hovered
|
||||
@@ -1315,57 +1329,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
@@ -1827,4 +1790,4 @@ audio.comfy-audio.empty-audio-widget {
|
||||
.maskEditor_sidePanelLayerCheckbox {
|
||||
margin-left: 15px;
|
||||
}
|
||||
/* ===================== End of Mask Editor Styles ===================== */
|
||||
/* ===================== End of Mask Editor Styles ===================== */
|
||||
|
||||
3
packages/design-system/src/icons/file-output.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.33398 1.33337V4.00004C9.33398 4.35366 9.47446 4.6928 9.72451 4.94285C9.97456 5.1929 10.3137 5.33337 10.6673 5.33337H13.334M2.66732 4.66671V2.66671C2.66732 2.31309 2.80779 1.97395 3.05784 1.7239C3.30789 1.47385 3.64703 1.33337 4.00065 1.33337H10.0006L13.334 4.66671V13.3334C13.334 13.687 13.1935 14.0261 12.9435 14.2762C12.6934 14.5262 12.3543 14.6667 12.0006 14.6667L4.04264 14.666C3.77927 14.7004 3.51166 14.6552 3.2741 14.5365C3.03655 14.4177 2.83988 14.2307 2.70931 13.9994M3.33398 7.33337L1.33398 9.33337M1.33398 9.33337L3.33398 11.3334M1.33398 9.33337H8.00065" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 771 B |
@@ -4,38 +4,80 @@
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="queue-history-toggle relative mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:class="queueHistoryButtonBackgroundClass"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--history] block size-4 text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
</div>
|
||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const queueHistoryButtonBackgroundClass = computed(() =>
|
||||
isQueueOverlayExpanded.value
|
||||
? 'bg-secondary-background-selected'
|
||||
: 'bg-secondary-background'
|
||||
)
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
@@ -45,6 +87,10 @@ onMounted(() => {
|
||||
legacyCommandsContainerRef.value.appendChild(app.menu.element)
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
class="pointer-events-auto z-1000"
|
||||
class="pointer-events-auto z-1010"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
@@ -260,6 +260,7 @@ const actionbarClass = computed(() =>
|
||||
'w-[265px] border-dashed border-blue-500 opacity-80',
|
||||
'm-1.5 flex items-center justify-center self-stretch',
|
||||
'rounded-md before:w-50 before:-ml-50 before:h-full',
|
||||
'pointer-events-auto',
|
||||
isMouseOverDropZone.value &&
|
||||
'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500'
|
||||
)
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
<div class="queue-button-group flex">
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: workspaceStore.shiftDown
|
||||
? $t('menu.runWorkflowFront')
|
||||
: $t('menu.runWorkflow'),
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
@@ -16,16 +14,7 @@
|
||||
@click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i v-if="workspaceStore.shiftDown" class="icon-[lucide--list-start]" />
|
||||
<i v-else-if="queueMode === 'disabled'" class="icon-[lucide--play]" />
|
||||
<i
|
||||
v-else-if="queueMode === 'instant'"
|
||||
class="icon-[lucide--fast-forward]"
|
||||
/>
|
||||
<i
|
||||
v-else-if="queueMode === 'change'"
|
||||
class="icon-[lucide--step-forward]"
|
||||
/>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
@@ -89,12 +78,15 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueSettingsStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
@@ -102,6 +94,11 @@ const workspaceStore = useWorkspaceStore()
|
||||
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
const items: Record<string, MenuItem> = {
|
||||
@@ -157,6 +154,35 @@ const hasPendingTasks = computed(
|
||||
() => queueCountStore.count.value > 1 || queueMode.value !== 'disabled'
|
||||
)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (hasMissingNodes.value) {
|
||||
return 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
if (workspaceStore.shiftDown) {
|
||||
return 'icon-[lucide--list-start]'
|
||||
}
|
||||
if (queueMode.value === 'disabled') {
|
||||
return 'icon-[lucide--play]'
|
||||
}
|
||||
if (queueMode.value === 'instant') {
|
||||
return 'icon-[lucide--fast-forward]'
|
||||
}
|
||||
if (queueMode.value === 'change') {
|
||||
return 'icon-[lucide--step-forward]'
|
||||
}
|
||||
return 'icon-[lucide--play]'
|
||||
})
|
||||
|
||||
const queueButtonTooltip = computed(() => {
|
||||
if (hasMissingNodes.value) {
|
||||
return t('menu.runWorkflowDisabled')
|
||||
}
|
||||
if (workspaceStore.shiftDown) {
|
||||
return t('menu.runWorkflowFront')
|
||||
}
|
||||
return t('menu.runWorkflow')
|
||||
})
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const queuePrompt = async (e: Event) => {
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a
|
||||
ref="wrapperRef"
|
||||
v-tooltip.bottom="{
|
||||
value: item.label,
|
||||
value: tooltipText,
|
||||
showDelay: 512
|
||||
}"
|
||||
draggable="false"
|
||||
@@ -16,6 +16,10 @@
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-if="hasMissingNodes && isRoot"
|
||||
class="icon-[lucide--triangle-alert] text-warning-background"
|
||||
/>
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
@@ -60,10 +64,13 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -74,6 +81,11 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
const dialogService = useDialogService()
|
||||
@@ -115,6 +127,14 @@ const rename = async (
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (hasMissingNodes.value && isRoot) {
|
||||
return t('breadcrumbsMenu.missingNodesWarning')
|
||||
}
|
||||
return props.item.label
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface IconButtonProps extends BaseButtonProps {
|
||||
onClick: (event: Event) => void
|
||||
onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
|
||||
@@ -47,7 +47,7 @@ const {
|
||||
} = defineProps<IconTextButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { label, variant = 'dark' } = defineProps<{
|
||||
label: string
|
||||
variant?: 'dark' | 'light'
|
||||
variant?: 'dark' | 'light' | 'gray'
|
||||
}>()
|
||||
|
||||
const baseClasses =
|
||||
@@ -19,7 +19,10 @@ const baseClasses =
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-zinc-500/40 text-white/90',
|
||||
light: cn('backdrop-blur-[2px] bg-base-background/50 text-base-foreground')
|
||||
light: cn('backdrop-blur-[2px] bg-base-background/50 text-base-foreground'),
|
||||
gray: cn(
|
||||
'backdrop-blur-[2px] bg-modal-card-tag-background text-base-foreground'
|
||||
)
|
||||
}
|
||||
|
||||
const chipClasses = computed(() => {
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down]" />
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'h-10 relative inline-flex cursor-pointer select-none',
|
||||
'rounded-lg bg-base-background text-base-foreground',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount > 0
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
<span class="text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-lg text-neutral-400" />
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div :class="wrapperStyle" @click="focusInput">
|
||||
<i class="icon-[lucide--search] text-muted" />
|
||||
<i class="icon-[lucide--search] text-muted-foreground" />
|
||||
<InputText
|
||||
ref="input"
|
||||
v-model="internalSearchQuery"
|
||||
@@ -73,7 +73,7 @@ onMounted(() => autofocus && focusInput())
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
const baseClasses =
|
||||
'relative flex w-full items-center gap-2 bg-base-background cursor-text'
|
||||
'relative flex w-full items-center gap-2 bg-secondary-background cursor-text'
|
||||
|
||||
if (showBorder) {
|
||||
return cn(
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
'rounded-lg',
|
||||
'bg-base-background text-base-foreground',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus-within:border-node-component-border',
|
||||
@@ -84,7 +84,7 @@
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-base text-neutral-500" />
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
|
||||
@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
-->
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
|
||||
73
src/components/queue/CompletionSummaryBanner.stories.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
|
||||
|
||||
const meta: Meta<typeof CompletionSummaryBanner> = {
|
||||
title: 'Queue/CompletionSummaryBanner',
|
||||
component: CompletionSummaryBanner,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const thumb = (hex: string) =>
|
||||
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23${hex}'/></svg>`
|
||||
|
||||
const thumbs = [thumb('ff6b6b'), thumb('4dabf7'), thumb('51cf66')]
|
||||
|
||||
export const AllSuccessSingle: Story = {
|
||||
args: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: [thumbs[0]]
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSuccessPlural: Story = {
|
||||
args: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 3,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: thumbs
|
||||
}
|
||||
}
|
||||
|
||||
export const MixedSingleSingle: Story = {
|
||||
args: {
|
||||
mode: 'mixed',
|
||||
completedCount: 1,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: thumbs.slice(0, 2)
|
||||
}
|
||||
}
|
||||
|
||||
export const MixedPluralPlural: Story = {
|
||||
args: {
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 3,
|
||||
thumbnailUrls: thumbs
|
||||
}
|
||||
}
|
||||
|
||||
export const AllFailedSingle: Story = {
|
||||
args: {
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: []
|
||||
}
|
||||
}
|
||||
|
||||
export const AllFailedPlural: Story = {
|
||||
args: {
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 4,
|
||||
thumbnailUrls: []
|
||||
}
|
||||
}
|
||||
91
src/components/queue/CompletionSummaryBanner.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
jobsCompleted: '{count} job completed | {count} jobs completed',
|
||||
jobsFailed: '{count} job failed | {count} jobs failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountComponent = (props: Record<string, unknown>) =>
|
||||
mount(CompletionSummaryBanner, {
|
||||
props: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe('CompletionSummaryBanner', () => {
|
||||
it('renders success mode text, thumbnails, and aria label', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 3,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: [
|
||||
'https://example.com/thumb-a.png',
|
||||
'https://example.com/thumb-b.png'
|
||||
],
|
||||
ariaLabel: 'Open queue summary'
|
||||
})
|
||||
|
||||
const button = wrapper.get('button')
|
||||
expect(button.attributes('aria-label')).toBe('Open queue summary')
|
||||
expect(wrapper.text()).toContain('3 jobs completed')
|
||||
|
||||
const thumbnailImages = wrapper.findAll('img')
|
||||
expect(thumbnailImages).toHaveLength(2)
|
||||
expect(thumbnailImages[0].attributes('src')).toBe(
|
||||
'https://example.com/thumb-a.png'
|
||||
)
|
||||
expect(thumbnailImages[1].attributes('src')).toBe(
|
||||
'https://example.com/thumb-b.png'
|
||||
)
|
||||
|
||||
const thumbnailContainers = wrapper.findAll('.inline-block.h-6.w-6')
|
||||
expect(thumbnailContainers[1].attributes('style')).toContain(
|
||||
'margin-left: -12px'
|
||||
)
|
||||
|
||||
expect(wrapper.html()).not.toContain('icon-[lucide--circle-alert]')
|
||||
})
|
||||
|
||||
it('renders mixed mode with success and failure counts', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1
|
||||
})
|
||||
|
||||
const summaryText = wrapper.text().replace(/\s+/g, ' ').trim()
|
||||
expect(summaryText).toContain('2 jobs completed, 1 job failed')
|
||||
})
|
||||
|
||||
it('renders failure mode icon without thumbnails', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 4
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('4 jobs failed')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
106
src/components/queue/CompletionSummaryBanner.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<IconButton
|
||||
type="secondary"
|
||||
size="fit-content"
|
||||
class="group w-full justify-between gap-3 rounded-lg 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"
|
||||
:class="'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="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>
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconButton from '@/components/button/IconButton.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>
|
||||
125
src/components/queue/QueueOverlayActive.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from './QueueOverlayActive.vue'
|
||||
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
total: 'Total: {percent}',
|
||||
currentNode: 'Current node:',
|
||||
running: 'running',
|
||||
interruptAll: 'Interrupt all running jobs',
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
viewAllJobs: 'View all jobs',
|
||||
cancelJobTooltip: 'Cancel job',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipDirectiveStub = {
|
||||
mounted: vi.fn(),
|
||||
updated: vi.fn()
|
||||
}
|
||||
|
||||
const SELECTORS = {
|
||||
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
|
||||
clearQueuedButton: 'button[aria-label="Clear queued"]',
|
||||
summaryRow: '.flex.items-center.gap-2',
|
||||
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
|
||||
}
|
||||
|
||||
const COPY = {
|
||||
viewAllJobs: 'View all jobs'
|
||||
}
|
||||
|
||||
const mountComponent = (props: Record<string, unknown> = {}) =>
|
||||
mount(QueueOverlayActive, {
|
||||
props: {
|
||||
totalProgressStyle: { width: '65%' },
|
||||
currentNodeProgressStyle: { width: '40%' },
|
||||
totalPercentFormatted: '65%',
|
||||
currentNodePercentFormatted: '40%',
|
||||
currentNodeName: 'Sampler',
|
||||
runningCount: 1,
|
||||
queuedCount: 2,
|
||||
bottomRowClass: 'flex custom-bottom-row',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
tooltip: tooltipDirectiveStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayActive', () => {
|
||||
it('renders progress metrics and emits actions when buttons clicked', async () => {
|
||||
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
|
||||
|
||||
const progressBars = wrapper.findAll('.absolute.inset-0')
|
||||
expect(progressBars[0].attributes('style')).toContain('width: 65%')
|
||||
expect(progressBars[1].attributes('style')).toContain('width: 40%')
|
||||
|
||||
const content = wrapper.text().replace(/\s+/g, ' ')
|
||||
expect(content).toContain('Total: 65%')
|
||||
|
||||
const [runningSection, queuedSection] = wrapper.findAll(
|
||||
SELECTORS.summaryRow
|
||||
)
|
||||
expect(runningSection.text()).toContain('2')
|
||||
expect(runningSection.text()).toContain('running')
|
||||
expect(queuedSection.text()).toContain('3')
|
||||
expect(queuedSection.text()).toContain('queued')
|
||||
|
||||
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
|
||||
expect(currentNodeSection.text()).toContain('Current node:')
|
||||
expect(currentNodeSection.text()).toContain('Sampler')
|
||||
expect(currentNodeSection.text()).toContain('40%')
|
||||
|
||||
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
|
||||
await interruptButton.trigger('click')
|
||||
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
|
||||
|
||||
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const viewAllButton = buttons.find((btn) =>
|
||||
btn.text().includes(COPY.viewAllJobs)
|
||||
)
|
||||
expect(viewAllButton).toBeDefined()
|
||||
await viewAllButton!.trigger('click')
|
||||
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
|
||||
|
||||
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides action buttons when counts are zero', () => {
|
||||
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
|
||||
|
||||
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
|
||||
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('builds tooltip configs with translated strings', () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
mountComponent()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('Cancel job')
|
||||
expect(spy).toHaveBeenCalledWith('Clear queue')
|
||||
})
|
||||
})
|
||||
125
src/components/queue/QueueOverlayActive.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="relative h-2 w-full overflow-hidden rounded-full border border-interface-stroke bg-interface-panel-surface"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 h-full rounded-full transition-[width]"
|
||||
:style="totalProgressStyle"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 h-full rounded-full transition-[width]"
|
||||
:style="currentNodeProgressStyle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-end gap-4 text-[12px] leading-none">
|
||||
<div class="flex items-center gap-1 text-text-primary opacity-90">
|
||||
<i18n-t keypath="sideToolbar.queueProgressOverlay.total">
|
||||
<template #percent>
|
||||
<span class="font-bold">{{ totalPercentFormatted }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-text-secondary">
|
||||
<span>{{ t('sideToolbar.queueProgressOverlay.currentNode') }}</span>
|
||||
<span class="inline-block max-w-[10rem] truncate">{{
|
||||
currentNodeName
|
||||
}}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span>{{ currentNodePercentFormatted }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="bottomRowClass">
|
||||
<div class="flex items-center gap-4 text-[12px] text-text-primary">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ runningCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.running')
|
||||
}}</span>
|
||||
</span>
|
||||
<IconButton
|
||||
v-if="runningCount > 0"
|
||||
v-tooltip.top="cancelJobTooltip"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||
@click="$emit('interruptAll')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<IconButton
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearQueueTooltip"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextButton
|
||||
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
|
||||
type="secondary"
|
||||
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
|
||||
@click="$emit('viewAllJobs')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
totalProgressStyle: Record<string, string>
|
||||
currentNodeProgressStyle: Record<string, string>
|
||||
totalPercentFormatted: string
|
||||
currentNodePercentFormatted: string
|
||||
currentNodeName: string
|
||||
runningCount: number
|
||||
queuedCount: number
|
||||
bottomRowClass: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'interruptAll'): void
|
||||
(e: 'clearQueued'): void
|
||||
(e: 'viewAllJobs'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const cancelJobTooltip = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
|
||||
)
|
||||
const clearQueueTooltip = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
|
||||
)
|
||||
</script>
|
||||
69
src/components/queue/QueueOverlayEmpty.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
|
||||
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
expandCollapsedQueue: 'Expand job queue',
|
||||
noActiveJobs: 'No active jobs'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CompletionSummaryBannerStub = {
|
||||
name: 'CompletionSummaryBanner',
|
||||
props: [
|
||||
'mode',
|
||||
'completedCount',
|
||||
'failedCount',
|
||||
'thumbnailUrls',
|
||||
'ariaLabel'
|
||||
],
|
||||
emits: ['click'],
|
||||
template: '<button class="summary-banner" @click="$emit(\'click\')"></button>'
|
||||
}
|
||||
|
||||
const mountComponent = (summary: CompletionSummary) =>
|
||||
mount(QueueOverlayEmpty, {
|
||||
props: { summary },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
components: { CompletionSummaryBanner: CompletionSummaryBannerStub }
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayEmpty', () => {
|
||||
it('renders completion summary banner and proxies click', async () => {
|
||||
const summary: CompletionSummary = {
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['thumb-a']
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(summary)
|
||||
const summaryBanner = wrapper.findComponent(CompletionSummaryBannerStub)
|
||||
|
||||
expect(summaryBanner.exists()).toBe(true)
|
||||
expect(summaryBanner.props()).toMatchObject({
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['thumb-a'],
|
||||
ariaLabel: 'Expand job queue'
|
||||
})
|
||||
|
||||
await summaryBanner.trigger('click')
|
||||
expect(wrapper.emitted('summaryClick')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
27
src/components/queue/QueueOverlayEmpty.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="pointer-events-auto">
|
||||
<CompletionSummaryBanner
|
||||
:mode="summary.mode"
|
||||
:completed-count="summary.completedCount"
|
||||
:failed-count="summary.failedCount"
|
||||
:thumbnail-urls="summary.thumbnailUrls"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')"
|
||||
@click="$emit('summaryClick')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue'
|
||||
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
|
||||
defineProps<{ summary: CompletionSummary }>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'summaryClick'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
151
src/components/queue/QueueOverlayExpanded.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<QueueOverlayHeader
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
@clear-history="$emit('clearHistory')"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3">
|
||||
<IconTextButton
|
||||
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
|
||||
type="secondary"
|
||||
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<template #icon>
|
||||
<div
|
||||
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<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>
|
||||
<IconButton
|
||||
v-if="queuedCount > 0"
|
||||
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i
|
||||
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobFiltersBar
|
||||
:selected-job-tab="selectedJobTab"
|
||||
:selected-workflow-filter="selectedWorkflowFilter"
|
||||
:selected-sort-mode="selectedSortMode"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
|
||||
@update:selected-workflow-filter="
|
||||
$emit('update:selectedWorkflowFilter', $event)
|
||||
"
|
||||
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<JobGroupsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
@delete-item="onDeleteItemEvent"
|
||||
@view-item="$emit('viewItem', $event)"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
JobSortMode,
|
||||
JobTab
|
||||
} from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
import JobFiltersBar from './job/JobFiltersBar.vue'
|
||||
import JobGroupsList from './job/JobGroupsList.vue'
|
||||
|
||||
defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
selectedJobTab: JobTab
|
||||
selectedWorkflowFilter: 'all' | 'current'
|
||||
selectedSortMode: JobSortMode
|
||||
displayedJobGroups: JobGroup[]
|
||||
hasFailedJobs: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showAssets'): void
|
||||
(e: 'clearHistory'): void
|
||||
(e: 'clearQueued'): void
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
|
||||
(e: 'update:selectedSortMode', value: JobSortMode): void
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
const { jobMenuEntries } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
(item) => emit('viewItem', item)
|
||||
)
|
||||
|
||||
const onCancelItemEvent = (item: JobListItem) => {
|
||||
emit('cancelItem', item)
|
||||
}
|
||||
|
||||
const onDeleteItemEvent = (item: JobListItem) => {
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
}
|
||||
</script>
|
||||
98
src/components/queue/QueueOverlayHeader.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const popoverToggleSpy = vi.fn()
|
||||
const popoverHideSpy = vi.fn()
|
||||
|
||||
vi.mock('primevue/popover', () => {
|
||||
const PopoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
setup(_, { slots, expose }) {
|
||||
const toggle = (event: Event) => {
|
||||
popoverToggleSpy(event)
|
||||
}
|
||||
const hide = () => {
|
||||
popoverHideSpy()
|
||||
}
|
||||
expose({ toggle, hide })
|
||||
return () => slots.default?.()
|
||||
}
|
||||
})
|
||||
return { default: PopoverStub }
|
||||
})
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
||||
|
||||
const tooltipDirectiveStub = {
|
||||
mounted: vi.fn(),
|
||||
updated: vi.fn()
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { more: 'More' },
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
running: 'running',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountHeader = (props = {}) =>
|
||||
mount(QueueOverlayHeader, {
|
||||
props: {
|
||||
headerTitle: 'Job queue',
|
||||
showConcurrentIndicator: true,
|
||||
concurrentWorkflowCount: 2,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: tooltipDirectiveStub }
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayHeader', () => {
|
||||
it('renders header title and concurrent indicator when enabled', () => {
|
||||
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
|
||||
|
||||
expect(wrapper.text()).toContain('Job queue')
|
||||
const indicator = wrapper.find('.inline-flex.items-center.gap-1')
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.text()).toContain('3')
|
||||
expect(indicator.text()).toContain('running')
|
||||
})
|
||||
|
||||
it('hides concurrent indicator when flag is false', () => {
|
||||
const wrapper = mountHeader({ showConcurrentIndicator: false })
|
||||
|
||||
expect(wrapper.text()).toContain('Job queue')
|
||||
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles popover and emits clear history', async () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const moreButton = wrapper.get('button[aria-label="More options"]')
|
||||
await moreButton.trigger('click')
|
||||
expect(popoverToggleSpy).toHaveBeenCalledTimes(1)
|
||||
expect(spy).toHaveBeenCalledWith('More')
|
||||
|
||||
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
|
||||
await clearHistoryButton.trigger('click')
|
||||
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
101
src/components/queue/QueueOverlayHeader.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
|
||||
>
|
||||
<div class="px-2 text-[14px] font-normal text-text-primary">
|
||||
<span>{{ headerTitle }}</span>
|
||||
<span
|
||||
v-if="showConcurrentIndicator"
|
||||
class="ml-4 inline-flex items-center gap-1 text-blue-100"
|
||||
>
|
||||
<span class="inline-block size-2 rounded-full bg-blue-100" />
|
||||
<span>
|
||||
<span class="font-bold">{{ concurrentWorkflowCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.running')
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</IconButton>
|
||||
<Popover
|
||||
ref="morePopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<IconTextButton
|
||||
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
@click="onClearHistoryFromMenu"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import type { PopoverMethods } from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const morePopoverRef = ref<PopoverMethods | null>(null)
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const onMoreClick = (event: MouseEvent) => {
|
||||
morePopoverRef.value?.toggle(event)
|
||||
}
|
||||
const onClearHistoryFromMenu = () => {
|
||||
morePopoverRef.value?.hide()
|
||||
emit('clearHistory')
|
||||
}
|
||||
</script>
|
||||
290
src/components/queue/QueueProgressOverlay.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="isVisible"
|
||||
:class="['flex', 'justify-end', 'w-full', 'pointer-events-none']"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto flex w-[350px] min-w-[310px] max-h-[60vh] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
|
||||
:class="containerClass"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<!-- Expanded state -->
|
||||
<QueueOverlayExpanded
|
||||
v-if="isExpanded"
|
||||
v-model:selected-job-tab="selectedJobTab"
|
||||
v-model:selected-workflow-filter="selectedWorkflowFilter"
|
||||
v-model:selected-sort-mode="selectedSortMode"
|
||||
class="flex-1 min-h-0"
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:queued-count="queuedCount"
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@show-assets="openAssetsSidebar"
|
||||
@clear-history="onClearHistoryFromMenu"
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="inspectJobAsset"
|
||||
/>
|
||||
|
||||
<QueueOverlayActive
|
||||
v-else-if="hasActiveJob"
|
||||
:total-progress-style="totalProgressStyle"
|
||||
:current-node-progress-style="currentNodeProgressStyle"
|
||||
:total-percent-formatted="totalPercentFormatted"
|
||||
:current-node-percent-formatted="currentNodePercentFormatted"
|
||||
:current-node-name="currentNodeName"
|
||||
:running-count="runningCount"
|
||||
:queued-count="queuedCount"
|
||||
:bottom-row-class="bottomRowClass"
|
||||
@interrupt-all="interruptAll"
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@view-all-jobs="viewAllJobs"
|
||||
/>
|
||||
|
||||
<QueueOverlayEmpty
|
||||
v-else-if="completionSummary"
|
||||
:summary="completionSummary"
|
||||
@summary-click="onSummaryClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
|
||||
const props = defineProps<{
|
||||
expanded?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetSelectionStore = useAssetSelectionStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const {
|
||||
totalPercentFormatted,
|
||||
currentNodePercentFormatted,
|
||||
totalProgressStyle,
|
||||
currentNodeProgressStyle
|
||||
} = useQueueProgress()
|
||||
const isHovered = ref(false)
|
||||
const internalExpanded = ref(false)
|
||||
const isExpanded = computed({
|
||||
get: () =>
|
||||
props.expanded === undefined ? internalExpanded.value : props.expanded,
|
||||
set: (value) => {
|
||||
if (props.expanded === undefined) {
|
||||
internalExpanded.value = value
|
||||
}
|
||||
emit('update:expanded', value)
|
||||
}
|
||||
})
|
||||
|
||||
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' && isHovered.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(() => overlayState.value !== 'hidden')
|
||||
|
||||
const containerClass = computed(() =>
|
||||
showBackground.value
|
||||
? 'border-interface-stroke bg-interface-panel-surface shadow-interface'
|
||||
: 'border-transparent bg-transparent shadow-none'
|
||||
)
|
||||
|
||||
const bottomRowClass = computed(
|
||||
() =>
|
||||
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
|
||||
overlayState.value === 'active' && isHovered.value
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
)
|
||||
const headerTitle = computed(() =>
|
||||
hasActiveJob.value
|
||||
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
|
||||
: t('sideToolbar.queueProgressOverlay.jobQueue')
|
||||
)
|
||||
|
||||
const concurrentWorkflowCount = computed(
|
||||
() => executionStore.runningWorkflowCount
|
||||
)
|
||||
const showConcurrentIndicator = computed(
|
||||
() => concurrentWorkflowCount.value > 1
|
||||
)
|
||||
|
||||
const {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
filteredTasks,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
} = useJobList()
|
||||
|
||||
const displayedJobGroups = computed(() => groupedJobItems.value)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const promptId = item.taskRef?.promptId
|
||||
if (!promptId) return
|
||||
await api.interrupt(promptId)
|
||||
})
|
||||
|
||||
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
if (!item.taskRef) return
|
||||
await queueStore.delete(item.taskRef)
|
||||
})
|
||||
|
||||
const {
|
||||
galleryActiveIndex,
|
||||
galleryItems,
|
||||
onViewItem: openResultGallery
|
||||
} = useResultGallery(() => filteredTasks.value)
|
||||
|
||||
const setExpanded = (expanded: boolean) => {
|
||||
isExpanded.value = expanded
|
||||
}
|
||||
|
||||
const openExpandedFromEmpty = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const viewAllJobs = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const onSummaryClick = () => {
|
||||
openExpandedFromEmpty()
|
||||
clearSummary()
|
||||
}
|
||||
|
||||
const openAssetsSidebar = () => {
|
||||
sidebarTabStore.activeSidebarTabId = 'assets'
|
||||
}
|
||||
|
||||
const focusAssetInSidebar = async (item: JobListItem) => {
|
||||
const task = item.taskRef
|
||||
const promptId = task?.promptId
|
||||
const preview = task?.previewOutput
|
||||
if (!promptId || !preview) return
|
||||
|
||||
const assetId = String(promptId)
|
||||
openAssetsSidebar()
|
||||
await nextTick()
|
||||
await assetsStore.updateHistory()
|
||||
const asset = assetsStore.historyAssets.find(
|
||||
(existingAsset) => existingAsset.id === assetId
|
||||
)
|
||||
if (!asset) {
|
||||
throw new Error('Asset not found in media assets panel')
|
||||
}
|
||||
assetSelectionStore.setSelection([assetId])
|
||||
}
|
||||
|
||||
const inspectJobAsset = wrapWithErrorHandlingAsync(
|
||||
async (item: JobListItem) => {
|
||||
openResultGallery(item)
|
||||
await focusAssetInSidebar(item)
|
||||
}
|
||||
)
|
||||
|
||||
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
})
|
||||
|
||||
const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
const tasks = queueStore.runningTasks
|
||||
await Promise.all(
|
||||
tasks
|
||||
.filter((task) => task.promptId != null)
|
||||
.map((task) => api.interrupt(task.promptId))
|
||||
)
|
||||
})
|
||||
|
||||
const showClearHistoryDialog = () => {
|
||||
dialogStore.showDialog({
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-[360px] w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 bg-transparent'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onClearHistoryFromMenu = () => {
|
||||
showClearHistoryDialog()
|
||||
}
|
||||
</script>
|
||||
90
src/components/queue/dialogs/QueueClearHistoryDialog.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<section
|
||||
class="w-[360px] rounded-2xl border border-interface-stroke bg-interface-panel-surface text-text-primary shadow-interface font-inter"
|
||||
>
|
||||
<header
|
||||
class="flex items-center justify-between border-b border-interface-stroke px-4 py-4"
|
||||
>
|
||||
<p class="m-0 text-[14px] font-normal leading-none">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
|
||||
</p>
|
||||
<IconButton
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-4 leading-none" />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
|
||||
<p class="m-0">
|
||||
{{
|
||||
t('sideToolbar.queueProgressOverlay.clearHistoryDialogDescription')
|
||||
}}
|
||||
</p>
|
||||
<p class="m-0">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogAssetsNote') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer class="flex items-center justify-end px-4 py-4">
|
||||
<div class="flex items-center gap-4 text-[14px] leading-none">
|
||||
<TextButton
|
||||
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
|
||||
type="transparent"
|
||||
:label="t('g.cancel')"
|
||||
@click="onCancel"
|
||||
/>
|
||||
<TextButton
|
||||
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
|
||||
type="secondary"
|
||||
:label="t('g.clear')"
|
||||
:disabled="isClearing"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { t } = useI18n()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const isClearing = ref(false)
|
||||
|
||||
const clearHistory = wrapWithErrorHandlingAsync(
|
||||
async () => {
|
||||
await queueStore.clear(['history'])
|
||||
dialogStore.closeDialog()
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
isClearing.value = false
|
||||
}
|
||||
)
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (isClearing.value) return
|
||||
isClearing.value = true
|
||||
await clearHistory()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
dialogStore.closeDialog()
|
||||
}
|
||||
</script>
|
||||
76
src/components/queue/job/JobContextMenu.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Popover
|
||||
ref="jobItemPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-[14rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<template v-for="entry in entries" :key="entry.key">
|
||||
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
|
||||
<div class="h-px bg-interface-stroke" />
|
||||
</div>
|
||||
<IconTextButton
|
||||
v-else
|
||||
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-interface-panel-hover-surface"
|
||||
type="transparent"
|
||||
:label="entry.label"
|
||||
:aria-label="entry.label"
|
||||
@click="onEntry(entry)"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="[
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
defineProps<{ entries: MenuEntry[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'action', entry: MenuEntry): void
|
||||
}>()
|
||||
|
||||
const jobItemPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
function open(event: Event) {
|
||||
if (jobItemPopoverRef.value) {
|
||||
jobItemPopoverRef.value.toggle(event)
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
jobItemPopoverRef.value?.hide()
|
||||
}
|
||||
|
||||
function onEntry(entry: MenuEntry) {
|
||||
emit('action', entry)
|
||||
}
|
||||
|
||||
defineExpose({ open, hide })
|
||||
</script>
|
||||
423
src/components/queue/job/JobDetailsPopover.stories.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { TaskStatus } from '@/schemas/apiSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
import JobDetailsPopover from './JobDetailsPopover.vue'
|
||||
|
||||
const meta: Meta<typeof JobDetailsPopover> = {
|
||||
title: 'Queue/JobDetailsPopover',
|
||||
component: JobDetailsPopover,
|
||||
args: {
|
||||
workflowId: 'WF-1234'
|
||||
},
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
backgrounds: {
|
||||
default: 'dark'
|
||||
}
|
||||
},
|
||||
globals: {
|
||||
theme: 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function resetStores() {
|
||||
const queue = useQueueStore()
|
||||
const exec = useExecutionStore()
|
||||
|
||||
queue.pendingTasks = []
|
||||
queue.runningTasks = []
|
||||
queue.historyTasks = []
|
||||
|
||||
exec.nodeProgressStatesByPrompt = {}
|
||||
}
|
||||
|
||||
function makePendingTask(
|
||||
id: string,
|
||||
index: number,
|
||||
createTimeMs?: number
|
||||
): TaskItemImpl {
|
||||
const extraData = {
|
||||
client_id: 'c1',
|
||||
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
|
||||
}
|
||||
return new TaskItemImpl('Pending', [index, id, {}, extraData, []])
|
||||
}
|
||||
|
||||
function makeRunningTask(
|
||||
id: string,
|
||||
index: number,
|
||||
createTimeMs?: number
|
||||
): TaskItemImpl {
|
||||
const extraData = {
|
||||
client_id: 'c1',
|
||||
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
|
||||
}
|
||||
return new TaskItemImpl('Running', [index, id, {}, extraData, []])
|
||||
}
|
||||
|
||||
function makeRunningTaskWithStart(
|
||||
id: string,
|
||||
index: number,
|
||||
startedSecondsAgo: number
|
||||
): TaskItemImpl {
|
||||
const start = Date.now() - startedSecondsAgo * 1000
|
||||
const status: TaskStatus = {
|
||||
status_str: 'success',
|
||||
completed: false,
|
||||
messages: [['execution_start', { prompt_id: id, timestamp: start } as any]]
|
||||
}
|
||||
return new TaskItemImpl(
|
||||
'Running',
|
||||
[index, id, {}, { client_id: 'c1', create_time: start - 5000 }, []],
|
||||
status
|
||||
)
|
||||
}
|
||||
|
||||
function makeHistoryTask(
|
||||
id: string,
|
||||
index: number,
|
||||
durationSec: number,
|
||||
ok: boolean,
|
||||
errorMessage?: string
|
||||
): TaskItemImpl {
|
||||
const start = Date.now() - durationSec * 1000 - 1000
|
||||
const end = start + durationSec * 1000
|
||||
const messages: TaskStatus['messages'] = ok
|
||||
? [
|
||||
['execution_start', { prompt_id: id, timestamp: start } as any],
|
||||
['execution_success', { prompt_id: id, timestamp: end } as any]
|
||||
]
|
||||
: [
|
||||
['execution_start', { prompt_id: id, timestamp: start } as any],
|
||||
[
|
||||
'execution_error',
|
||||
{
|
||||
prompt_id: id,
|
||||
timestamp: end,
|
||||
node_id: '1',
|
||||
node_type: 'Node',
|
||||
executed: [],
|
||||
exception_message:
|
||||
errorMessage || 'Demo error: Node failed during execution',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
} as any
|
||||
]
|
||||
]
|
||||
const status: TaskStatus = {
|
||||
status_str: ok ? 'success' : 'error',
|
||||
completed: true,
|
||||
messages
|
||||
}
|
||||
return new TaskItemImpl(
|
||||
'History',
|
||||
[index, id, {}, { client_id: 'c1', create_time: start }, []],
|
||||
status
|
||||
)
|
||||
}
|
||||
|
||||
export const Queued: Story = {
|
||||
render: (args) => ({
|
||||
components: { JobDetailsPopover },
|
||||
setup() {
|
||||
resetStores()
|
||||
const queue = useQueueStore()
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-1'
|
||||
const queueIndex = 104
|
||||
|
||||
// Current job in pending
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
|
||||
]
|
||||
// Add some other pending jobs to give context
|
||||
queue.pendingTasks.push(makePendingTask('job-older-1', 100))
|
||||
queue.pendingTasks.push(makePendingTask('job-older-2', 101))
|
||||
|
||||
// Queued at (in metadata on prompt[4])
|
||||
|
||||
// One running workflow
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
max: 1,
|
||||
state: 'running',
|
||||
node_id: '1',
|
||||
prompt_id: 'p1'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
|
||||
<JobDetailsPopover v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const QueuedParallel: Story = {
|
||||
render: (args) => ({
|
||||
components: { JobDetailsPopover },
|
||||
setup() {
|
||||
resetStores()
|
||||
const queue = useQueueStore()
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-parallel'
|
||||
const queueIndex = 210
|
||||
|
||||
// Current job in pending with some ahead
|
||||
queue.pendingTasks = [
|
||||
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
|
||||
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
|
||||
]
|
||||
|
||||
// Seen 2 minutes ago - set via prompt metadata above
|
||||
|
||||
// History durations for ETA (in seconds)
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask('hist-1', 150, 25, true),
|
||||
makeHistoryTask('hist-2', 151, 40, true),
|
||||
makeHistoryTask('hist-3', 152, 60, true)
|
||||
]
|
||||
|
||||
// Two parallel workflows running
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
max: 2,
|
||||
state: 'running',
|
||||
node_id: '1',
|
||||
prompt_id: 'p1'
|
||||
}
|
||||
},
|
||||
p2: {
|
||||
'2': {
|
||||
value: 1,
|
||||
max: 2,
|
||||
state: 'running',
|
||||
node_id: '2',
|
||||
prompt_id: 'p2'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
|
||||
<JobDetailsPopover v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Running: Story = {
|
||||
render: (args) => ({
|
||||
components: { JobDetailsPopover },
|
||||
setup() {
|
||||
resetStores()
|
||||
const queue = useQueueStore()
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-running-1'
|
||||
const queueIndex = 300
|
||||
queue.runningTasks = [
|
||||
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
|
||||
]
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask('hist-r1', 250, 30, true),
|
||||
makeHistoryTask('hist-r2', 251, 45, true),
|
||||
makeHistoryTask('hist-r3', 252, 60, true)
|
||||
]
|
||||
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running',
|
||||
node_id: '1',
|
||||
prompt_id: 'p1'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
|
||||
<JobDetailsPopover v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const QueuedZeroAheadSingleRunning: Story = {
|
||||
render: (args) => ({
|
||||
components: { JobDetailsPopover },
|
||||
setup() {
|
||||
resetStores()
|
||||
const queue = useQueueStore()
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-zero-ahead-single'
|
||||
const queueIndex = 510
|
||||
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
|
||||
]
|
||||
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask('hist-s1', 480, 30, true),
|
||||
makeHistoryTask('hist-s2', 481, 50, true),
|
||||
makeHistoryTask('hist-s3', 482, 80, true)
|
||||
]
|
||||
|
||||
queue.runningTasks = [makeRunningTaskWithStart('running-1', 505, 20)]
|
||||
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
max: 3,
|
||||
state: 'running',
|
||||
node_id: '1',
|
||||
prompt_id: 'p1'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
|
||||
<JobDetailsPopover v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const QueuedZeroAheadMultiRunning: Story = {
|
||||
render: (args) => ({
|
||||
components: { JobDetailsPopover },
|
||||
setup() {
|
||||
resetStores()
|
||||
const queue = useQueueStore()
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-zero-ahead-multi'
|
||||
const queueIndex = 520
|
||||
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
|
||||
]
|
||||
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask('hist-m1', 490, 40, true),
|
||||
makeHistoryTask('hist-m2', 491, 55, true),
|
||||
makeHistoryTask('hist-m3', 492, 70, true)
|
||||
]
|
||||
|
||||
queue.runningTasks = [
|
||||
makeRunningTaskWithStart('running-a', 506, 35),
|
||||
makeRunningTaskWithStart('running-b', 507, 10)
|
||||
]
|
||||
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 2,
|
||||
max: 5,
|
||||
state: 'running',
|
||||
node_id: '1',
|
||||
prompt_id: 'p1'
|
||||
}
|
||||
},
|
||||
p2: {
|
||||
'2': {
|
||||
value: 3,
|
||||
max: 5,
|
||||
state: 'running',
|
||||
node_id: '2',
|
||||
prompt_id: 'p2'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
|
||||
<JobDetailsPopover v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
render: (args) => ({
|
||||
components: { JobDetailsPopover },
|
||||
setup() {
|
||||
resetStores()
|
||||
const queue = useQueueStore()
|
||||
|
||||
const jobId = 'job-completed-1'
|
||||
const queueIndex = 400
|
||||
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
|
||||
<JobDetailsPopover v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
render: (args) => ({
|
||||
components: { JobDetailsPopover },
|
||||
setup() {
|
||||
resetStores()
|
||||
const queue = useQueueStore()
|
||||
|
||||
const jobId = 'job-failed-1'
|
||||
const queueIndex = 410
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask(
|
||||
jobId,
|
||||
queueIndex,
|
||||
12,
|
||||
false,
|
||||
'Example error: invalid inputs for node X'
|
||||
)
|
||||
]
|
||||
// Show a queued-at time for the failed job via history extra_data (2 minutes ago)
|
||||
// Already set by makeHistoryTask using its start timestamp
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
|
||||
<JobDetailsPopover v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
359
src/components/queue/job/JobDetailsPopover.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-[300px] min-w-[260px] rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-md"
|
||||
>
|
||||
<div class="flex items-center border-b border-interface-stroke p-4">
|
||||
<span
|
||||
class="text-[0.875rem] leading-normal font-normal text-text-primary"
|
||||
>{{ t('queue.jobDetails.header') }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 px-4 pt-4 pb-4">
|
||||
<div class="grid grid-cols-2 items-center gap-x-2 gap-y-2">
|
||||
<template v-for="row in baseRows" :key="row.label">
|
||||
<div
|
||||
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
|
||||
>
|
||||
{{ row.label }}
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
|
||||
>
|
||||
<span class="block min-w-0 truncate">{{ row.value }}</span>
|
||||
<IconButton
|
||||
v-if="row.canCopy"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="ml-2 size-6 bg-transparent hover:opacity-90"
|
||||
:aria-label="copyAriaLabel"
|
||||
@click.stop="copyJobId"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="extraRows.length"
|
||||
class="grid grid-cols-2 items-center gap-x-2 gap-y-2"
|
||||
>
|
||||
<template v-for="row in extraRows" :key="row.label">
|
||||
<div
|
||||
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
|
||||
>
|
||||
{{ row.label }}
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
|
||||
>
|
||||
<span class="block min-w-0 truncate">{{ row.value }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="jobState === 'failed'" class="grid grid-cols-2 gap-x-2">
|
||||
<div
|
||||
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
|
||||
>
|
||||
{{ t('queue.jobDetails.errorMessage') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<IconTextButton
|
||||
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="copyAriaLabel"
|
||||
:aria-label="copyAriaLabel"
|
||||
icon-position="right"
|
||||
@click.stop="copyErrorMessage"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="t('queue.jobDetails.report')"
|
||||
icon-position="right"
|
||||
@click.stop="reportJobError"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div
|
||||
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
|
||||
>
|
||||
{{ errorMessageValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { formatClockTime } from '@/utils/dateTimeUtil'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
import { useJobErrorReporting } from './useJobErrorReporting'
|
||||
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
|
||||
|
||||
const props = defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
}>()
|
||||
|
||||
const copyAriaLabel = computed(() => t('g.copy'))
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const dialog = useDialogService()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const workflowValue = computed(() => {
|
||||
const wid = props.workflowId
|
||||
if (!wid) return ''
|
||||
const activeId = workflowStore.activeWorkflow?.activeState?.id
|
||||
if (activeId && activeId === wid) {
|
||||
return workflowStore.activeWorkflow?.filename ?? wid
|
||||
}
|
||||
return wid
|
||||
})
|
||||
const jobIdValue = computed(() => props.jobId)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const copyJobId = () => void copyToClipboard(jobIdValue.value)
|
||||
|
||||
const taskForJob = computed(() => {
|
||||
const pid = props.jobId
|
||||
const findIn = (arr: TaskItemImpl[]) =>
|
||||
arr.find((t) => String(t.promptId ?? '') === String(pid))
|
||||
return (
|
||||
findIn(queueStore.pendingTasks) ||
|
||||
findIn(queueStore.runningTasks) ||
|
||||
findIn(queueStore.historyTasks) ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const jobState = computed(() => {
|
||||
const task = taskForJob.value
|
||||
if (!task) return null
|
||||
const isInitializing = executionStore.isPromptInitializing(
|
||||
String(task?.promptId)
|
||||
)
|
||||
return jobStateFromTask(task, isInitializing)
|
||||
})
|
||||
|
||||
const firstSeenTs = computed<number | undefined>(() => {
|
||||
const task = taskForJob.value
|
||||
return task?.createTime
|
||||
})
|
||||
|
||||
const queuedAtValue = computed(() =>
|
||||
firstSeenTs.value !== undefined
|
||||
? formatClockTime(firstSeenTs.value, locale.value)
|
||||
: ''
|
||||
)
|
||||
|
||||
const currentQueueIndex = computed<number | null>(() => {
|
||||
const task = taskForJob.value
|
||||
return task ? Number(task.queueIndex) : null
|
||||
})
|
||||
|
||||
const jobsAhead = computed<number | null>(() => {
|
||||
const idx = currentQueueIndex.value
|
||||
if (idx == null) return null
|
||||
const ahead = queueStore.pendingTasks.filter(
|
||||
(t: TaskItemImpl) => Number(t.queueIndex) < idx
|
||||
)
|
||||
return ahead.length
|
||||
})
|
||||
|
||||
const queuePositionValue = computed(() => {
|
||||
if (jobsAhead.value == null) return ''
|
||||
const n = jobsAhead.value
|
||||
return t('queue.jobDetails.queuePositionValue', { count: n }, n)
|
||||
})
|
||||
|
||||
const nowTs = ref<number>(Date.now())
|
||||
let timer: number | null = null
|
||||
onMounted(() => {
|
||||
timer = window.setInterval(() => {
|
||||
nowTs.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (timer != null) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
showParallelQueuedStats,
|
||||
estimateRangeSeconds,
|
||||
estimateRemainingRangeSeconds,
|
||||
timeElapsedValue
|
||||
} = useQueueEstimates({
|
||||
queueStore,
|
||||
executionStore,
|
||||
taskForJob,
|
||||
jobState,
|
||||
firstSeenTs,
|
||||
jobsAhead,
|
||||
nowTs
|
||||
})
|
||||
|
||||
const formatEta = (lo: number, hi: number): string => {
|
||||
if (hi <= 60) {
|
||||
const hiS = Math.max(1, Math.round(hi))
|
||||
const loS = Math.max(1, Math.min(hiS, Math.round(lo)))
|
||||
if (loS === hiS)
|
||||
return t('queue.jobDetails.eta.seconds', { count: hiS }, hiS)
|
||||
return t('queue.jobDetails.eta.secondsRange', { lo: loS, hi: hiS })
|
||||
}
|
||||
if (lo >= 60 && hi < 90) {
|
||||
return t('queue.jobDetails.eta.minutes', { count: 1 }, 1)
|
||||
}
|
||||
const loM = Math.max(1, Math.floor(lo / 60))
|
||||
const hiM = Math.max(loM, Math.ceil(hi / 60))
|
||||
if (loM === hiM) {
|
||||
return t('queue.jobDetails.eta.minutes', { count: loM }, loM)
|
||||
}
|
||||
return t('queue.jobDetails.eta.minutesRange', { lo: loM, hi: hiM })
|
||||
}
|
||||
|
||||
const estimatedStartInValue = computed(() => {
|
||||
const range = estimateRangeSeconds.value
|
||||
if (!range) return ''
|
||||
const [lo, hi] = range
|
||||
return formatEta(lo, hi)
|
||||
})
|
||||
|
||||
const estimatedFinishInValue = computed(() => {
|
||||
const range = estimateRemainingRangeSeconds.value
|
||||
if (!range) return ''
|
||||
const [lo, hi] = range
|
||||
return formatEta(lo, hi)
|
||||
})
|
||||
|
||||
type DetailRow = { label: string; value: string; canCopy?: boolean }
|
||||
|
||||
const baseRows = computed<DetailRow[]>(() => [
|
||||
{ label: t('queue.jobDetails.workflow'), value: workflowValue.value },
|
||||
{ label: t('queue.jobDetails.jobId'), value: jobIdValue.value, canCopy: true }
|
||||
])
|
||||
|
||||
const extraRows = computed<DetailRow[]>(() => {
|
||||
if (jobState.value === 'pending') {
|
||||
if (!firstSeenTs.value) return []
|
||||
const rows: DetailRow[] = [
|
||||
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value }
|
||||
]
|
||||
if (showParallelQueuedStats.value) {
|
||||
rows.push(
|
||||
{
|
||||
label: t('queue.jobDetails.queuePosition'),
|
||||
value: queuePositionValue.value
|
||||
},
|
||||
{
|
||||
label: t('queue.jobDetails.timeElapsed'),
|
||||
value: timeElapsedValue.value
|
||||
},
|
||||
{
|
||||
label: t('queue.jobDetails.estimatedStartIn'),
|
||||
value: estimatedStartInValue.value
|
||||
}
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
if (jobState.value === 'running') {
|
||||
if (!firstSeenTs.value) return []
|
||||
return [
|
||||
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
|
||||
{
|
||||
label: t('queue.jobDetails.timeElapsed'),
|
||||
value: timeElapsedValue.value
|
||||
},
|
||||
{
|
||||
label: t('queue.jobDetails.estimatedFinishIn'),
|
||||
value: estimatedFinishInValue.value
|
||||
}
|
||||
]
|
||||
}
|
||||
if (jobState.value === 'completed') {
|
||||
const task = taskForJob.value as any
|
||||
const endTs: number | undefined = task?.executionEndTimestamp
|
||||
const execMs: number | undefined = task?.executionTime
|
||||
const generatedOnValue = endTs ? formatClockTime(endTs, locale.value) : ''
|
||||
const totalGenTimeValue =
|
||||
execMs !== undefined ? formatElapsedTime(execMs) : ''
|
||||
const computeHoursValue =
|
||||
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
|
||||
|
||||
const rows: DetailRow[] = [
|
||||
{ label: t('queue.jobDetails.generatedOn'), value: generatedOnValue },
|
||||
{
|
||||
label: t('queue.jobDetails.totalGenerationTime'),
|
||||
value: totalGenTimeValue
|
||||
}
|
||||
]
|
||||
if (isCloud) {
|
||||
rows.push({
|
||||
label: t('queue.jobDetails.computeHoursUsed'),
|
||||
value: computeHoursValue
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
if (jobState.value === 'failed') {
|
||||
const task = taskForJob.value as any
|
||||
const execMs: number | undefined = task?.executionTime
|
||||
const failedAfterValue =
|
||||
execMs !== undefined ? formatElapsedTime(execMs) : ''
|
||||
const computeHoursValue =
|
||||
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
|
||||
const rows: DetailRow[] = [
|
||||
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
|
||||
{ label: t('queue.jobDetails.failedAfter'), value: failedAfterValue }
|
||||
]
|
||||
if (isCloud) {
|
||||
rows.push({
|
||||
label: t('queue.jobDetails.computeHoursUsed'),
|
||||
value: computeHoursValue
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const { errorMessageValue, copyErrorMessage, reportJobError } =
|
||||
useJobErrorReporting({
|
||||
taskForJob,
|
||||
copyToClipboard,
|
||||
dialog
|
||||
})
|
||||
</script>
|
||||
231
src/components/queue/job/JobFiltersBar.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-2 px-3">
|
||||
<div class="min-w-0 flex-1 overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
<TextButton
|
||||
v-for="tab in visibleJobTabs"
|
||||
:key="tab"
|
||||
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
|
||||
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
|
||||
:class="[
|
||||
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
|
||||
]"
|
||||
:label="tabLabel(tab)"
|
||||
@click="$emit('update:selectedJobTab', tab)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-2">
|
||||
<IconButton
|
||||
v-if="showWorkflowFilter"
|
||||
v-tooltip.top="filterTooltipConfig"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
|
||||
@click="onFilterClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--list-filter] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-if="selectedWorkflowFilter !== 'all'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</IconButton>
|
||||
<Popover
|
||||
v-if="showWorkflowFilter"
|
||||
ref="filterPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
|
||||
"
|
||||
@click="selectWorkflowFilter('all')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'all'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div class="mx-2 mt-1 h-px" />
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
|
||||
"
|
||||
@click="selectWorkflowFilter('current')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'current'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
<IconButton
|
||||
v-tooltip.top="sortTooltipConfig"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
|
||||
@click="onSortClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--arrow-up-down] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-if="selectedSortMode !== 'mostRecent'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</IconButton>
|
||||
<Popover
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<template v-for="(mode, index) in jobSortModes" :key="mode">
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="sortLabel(mode)"
|
||||
:aria-label="sortLabel(mode)"
|
||||
@click="selectSortMode(mode)"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedSortMode === mode"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div
|
||||
v-if="index < jobSortModes.length - 1"
|
||||
class="mx-2 mt-1 h-px"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
|
||||
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedJobTab: JobTab
|
||||
selectedWorkflowFilter: 'all' | 'current'
|
||||
selectedSortMode: JobSortMode
|
||||
hasFailedJobs: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
|
||||
(e: 'update:selectedSortMode', value: JobSortMode): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const sortPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const filterTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
|
||||
)
|
||||
const sortTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
|
||||
)
|
||||
|
||||
// This can be removed when cloud implements /jobs and we switch to it.
|
||||
const showWorkflowFilter = !isCloud
|
||||
|
||||
const visibleJobTabs = computed(() =>
|
||||
props.hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
|
||||
)
|
||||
|
||||
const onFilterClick = (event: Event) => {
|
||||
if (filterPopoverRef.value) {
|
||||
filterPopoverRef.value.toggle(event)
|
||||
}
|
||||
}
|
||||
const selectWorkflowFilter = (value: 'all' | 'current') => {
|
||||
;(filterPopoverRef.value as any)?.hide?.()
|
||||
emit('update:selectedWorkflowFilter', value)
|
||||
}
|
||||
|
||||
const onSortClick = (event: Event) => {
|
||||
if (sortPopoverRef.value) {
|
||||
sortPopoverRef.value.toggle(event)
|
||||
}
|
||||
}
|
||||
|
||||
const selectSortMode = (value: JobSortMode) => {
|
||||
;(sortPopoverRef.value as any)?.hide?.()
|
||||
emit('update:selectedSortMode', value)
|
||||
}
|
||||
|
||||
const tabLabel = (tab: JobTab) => {
|
||||
if (tab === 'All') return t('g.all')
|
||||
if (tab === 'Completed') return t('g.completed')
|
||||
return t('g.failed')
|
||||
}
|
||||
|
||||
const sortLabel = (mode: JobSortMode) => {
|
||||
if (mode === 'mostRecent') {
|
||||
return t('queue.jobList.sortMostRecent')
|
||||
}
|
||||
if (mode === 'totalGenerationTime') {
|
||||
return t('queue.jobList.sortTotalGenerationTime')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
92
src/components/queue/job/JobGroupsList.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-3 pb-4">
|
||||
<div
|
||||
v-for="group in displayedJobGroups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="text-[12px] leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<QueueJobItem
|
||||
v-for="ji in group.items"
|
||||
:key="ji.id"
|
||||
:job-id="ji.id"
|
||||
:workflow-id="ji.taskRef?.workflow?.id"
|
||||
:state="ji.state"
|
||||
:title="ji.title"
|
||||
:right-text="ji.meta"
|
||||
:icon-name="ji.iconName"
|
||||
:icon-image-url="ji.iconImageUrl"
|
||||
:show-clear="ji.showClear"
|
||||
:show-menu="true"
|
||||
:progress-total-percent="ji.progressTotalPercent"
|
||||
:progress-current-percent="ji.progressCurrentPercent"
|
||||
:running-node-name="ji.runningNodeName"
|
||||
:active-details-id="activeDetailsId"
|
||||
@cancel="emitCancelItem(ji)"
|
||||
@delete="emitDeleteItem(ji)"
|
||||
@menu="(ev) => $emit('menu', ji, ev)"
|
||||
@view="$emit('viewItem', ji)"
|
||||
@details-enter="onDetailsEnter"
|
||||
@details-leave="onDetailsLeave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const emitCancelItem = (item: JobListItem) => {
|
||||
emit('cancelItem', item)
|
||||
}
|
||||
|
||||
const emitDeleteItem = (item: JobListItem) => {
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
|
||||
const activeDetailsId = ref<string | null>(null)
|
||||
const hideTimer = ref<number | null>(null)
|
||||
const showTimer = ref<number | null>(null)
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimer.value !== null) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
}
|
||||
}
|
||||
const clearShowTimer = () => {
|
||||
if (showTimer.value !== null) {
|
||||
clearTimeout(showTimer.value)
|
||||
showTimer.value = null
|
||||
}
|
||||
}
|
||||
const onDetailsEnter = (jobId: string) => {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
showTimer.value = window.setTimeout(() => {
|
||||
activeDetailsId.value = jobId
|
||||
showTimer.value = null
|
||||
}, 200)
|
||||
}
|
||||
const onDetailsLeave = (jobId: string) => {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
hideTimer.value = window.setTimeout(() => {
|
||||
if (activeDetailsId.value === jobId) activeDetailsId.value = null
|
||||
hideTimer.value = null
|
||||
}, 150)
|
||||
}
|
||||
</script>
|
||||
65
src/components/queue/job/QueueAssetPreview.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="w-[300px] min-w-[260px] rounded-lg shadow-md">
|
||||
<div class="p-3">
|
||||
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
ref="imgRef"
|
||||
:src="imageUrl"
|
||||
:alt="name"
|
||||
class="h-full w-full cursor-pointer object-contain"
|
||||
@click="$emit('image-click')"
|
||||
@load="onImgLoad"
|
||||
/>
|
||||
<div
|
||||
v-if="timeLabel"
|
||||
class="absolute bottom-2 left-2 rounded px-2 py-0.5 text-xs text-text-primary"
|
||||
:style="{
|
||||
background: 'rgba(217, 217, 217, 0.40)',
|
||||
backdropFilter: 'blur(2px)'
|
||||
}"
|
||||
>
|
||||
{{ timeLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-center">
|
||||
<div
|
||||
class="truncate text-[0.875rem] leading-normal font-semibold text-text-primary"
|
||||
:title="name"
|
||||
>
|
||||
{{ name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="width && height"
|
||||
class="mt-1 text-[0.75rem] leading-normal text-text-secondary"
|
||||
>
|
||||
{{ width }}x{{ height }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
defineProps<{
|
||||
imageUrl: string
|
||||
name: string
|
||||
timeLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits(['image-click'])
|
||||
|
||||
const imgRef = ref<HTMLImageElement | null>(null)
|
||||
const width = ref<number | null>(null)
|
||||
const height = ref<number | null>(null)
|
||||
|
||||
const onImgLoad = () => {
|
||||
const el = imgRef.value
|
||||
if (!el) return
|
||||
width.value = el.naturalWidth || null
|
||||
height.value = el.naturalHeight || null
|
||||
}
|
||||
</script>
|
||||
134
src/components/queue/job/QueueJobItem.stories.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import QueueJobItem from './QueueJobItem.vue'
|
||||
|
||||
const meta: Meta<typeof QueueJobItem> = {
|
||||
title: 'Queue/QueueJobItem',
|
||||
component: QueueJobItem,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
},
|
||||
argTypes: {
|
||||
onCancel: { action: 'cancel' },
|
||||
onDelete: { action: 'delete' },
|
||||
onMenu: { action: 'menu' },
|
||||
onView: { action: 'view' }
|
||||
}
|
||||
}
|
||||
|
||||
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='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
|
||||
|
||||
export const PendingRecentlyAdded: Story = {
|
||||
args: {
|
||||
jobId: 'job-pending-added-1',
|
||||
state: 'pending',
|
||||
title: 'Job added to queue',
|
||||
rightText: '12:30 PM',
|
||||
iconName: 'icon-[lucide--check]'
|
||||
}
|
||||
}
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
jobId: 'job-pending-1',
|
||||
state: 'pending',
|
||||
title: 'Pending job',
|
||||
rightText: '12:31 PM'
|
||||
}
|
||||
}
|
||||
|
||||
export const Initialization: Story = {
|
||||
args: {
|
||||
jobId: 'job-init-1',
|
||||
state: 'initialization',
|
||||
title: 'Initializing...'
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningTotalOnly: Story = {
|
||||
args: {
|
||||
jobId: 'job-running-1',
|
||||
state: 'running',
|
||||
title: 'Generating image',
|
||||
progressTotalPercent: 42
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningWithCurrent: Story = {
|
||||
args: {
|
||||
jobId: 'job-running-2',
|
||||
state: 'running',
|
||||
title: 'Generating image',
|
||||
progressTotalPercent: 66,
|
||||
progressCurrentPercent: 10,
|
||||
runningNodeName: 'KSampler'
|
||||
}
|
||||
}
|
||||
|
||||
export const CompletedWithPreview: Story = {
|
||||
args: {
|
||||
jobId: 'job-completed-1',
|
||||
state: 'completed',
|
||||
title: 'Prompt #1234',
|
||||
rightText: '12.79s',
|
||||
iconImageUrl: thumb('4dabf7')
|
||||
}
|
||||
}
|
||||
|
||||
export const CompletedNoPreview: Story = {
|
||||
args: {
|
||||
jobId: 'job-completed-2',
|
||||
state: 'completed',
|
||||
title: 'Prompt #5678',
|
||||
rightText: '8.12s'
|
||||
}
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
jobId: 'job-failed-1',
|
||||
state: 'failed',
|
||||
title: 'Failed job',
|
||||
rightText: 'Failed'
|
||||
}
|
||||
}
|
||||
|
||||
export const Gallery: Story = {
|
||||
render: (args) => ({
|
||||
components: { QueueJobItem },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2 w-[420px]">
|
||||
<QueueJobItem job-id="job-pending-added-1" state="pending" title="Job added to queue" right-text="12:30 PM" icon-name="icon-[lucide--check]" v-bind="args" />
|
||||
<QueueJobItem job-id="job-pending-1" state="pending" title="Pending job" right-text="12:31 PM" v-bind="args" />
|
||||
<QueueJobItem job-id="job-init-1" state="initialization" title="Initializing..." v-bind="args" />
|
||||
<QueueJobItem job-id="job-running-1" state="running" title="Generating image" :progress-total-percent="42" v-bind="args" />
|
||||
<QueueJobItem
|
||||
job-id="job-running-2"
|
||||
state="running"
|
||||
title="Generating image"
|
||||
:progress-total-percent="66"
|
||||
:progress-current-percent="10"
|
||||
running-node-name="KSampler"
|
||||
v-bind="args"
|
||||
/>
|
||||
<QueueJobItem
|
||||
job-id="job-completed-1"
|
||||
state="completed"
|
||||
title="Prompt #1234"
|
||||
right-text="12.79s"
|
||||
icon-image-url="${thumb('4dabf7')}"
|
||||
v-bind="args"
|
||||
/>
|
||||
<QueueJobItem job-id="job-completed-2" state="completed" title="Prompt #5678" right-text="8.12s" v-bind="args" />
|
||||
<QueueJobItem job-id="job-failed-1" state="failed" title="Failed job" right-text="Failed" v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
314
src/components/queue/job/QueueJobItem.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rowRef"
|
||||
class="relative"
|
||||
@mouseenter="onRowEnter"
|
||||
@mouseleave="onRowLeave"
|
||||
@contextmenu.stop.prevent="onContextMenu"
|
||||
>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="!isPreviewVisible && showDetails && popoverPosition"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
right: `${popoverPosition.right}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
>
|
||||
<JobDetailsPopover
|
||||
:job-id="props.jobId"
|
||||
:workflow-id="props.workflowId"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isPreviewVisible && canShowPreview && popoverPosition"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
right: `${popoverPosition.right}px`
|
||||
}"
|
||||
@mouseenter="onPreviewEnter"
|
||||
@mouseleave="onPreviewLeave"
|
||||
>
|
||||
<QueueAssetPreview
|
||||
:image-url="iconImageUrl!"
|
||||
:name="props.title"
|
||||
:time-label="rightText || undefined"
|
||||
@image-click="emit('view')"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
<div
|
||||
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
props.state === 'running' &&
|
||||
(props.progressTotalPercent !== undefined ||
|
||||
props.progressCurrentPercent !== undefined)
|
||||
"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<div
|
||||
v-if="props.progressTotalPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${props.progressTotalPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
v-if="props.progressCurrentPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${props.progressCurrentPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-1">
|
||||
<div class="relative inline-flex items-center justify-center">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
|
||||
@mouseenter.stop="onIconEnter"
|
||||
@mouseleave.stop="onIconLeave"
|
||||
/>
|
||||
<div
|
||||
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
|
||||
>
|
||||
<img
|
||||
v-if="iconImageUrl"
|
||||
:src="iconImageUrl"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<i v-else :class="[iconClass, 'size-4']" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] min-w-0 flex-1">
|
||||
<div class="truncate opacity-90" :title="props.title">
|
||||
<slot name="primary">{{ props.title }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
|
||||
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-0.5"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-0.5"
|
||||
>
|
||||
<div
|
||||
v-if="isHovered"
|
||||
key="actions"
|
||||
class="inline-flex items-center gap-2 pr-1"
|
||||
>
|
||||
<IconButton
|
||||
v-if="props.state === 'failed' && computedShowClear"
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="props.state !== 'completed' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
<TextButton
|
||||
v-else-if="props.state === 'completed'"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
type="transparent"
|
||||
:label="t('menuLabels.View')"
|
||||
:aria-label="t('menuLabels.View')"
|
||||
@click.stop="emit('view')"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="props.showMenu !== undefined ? props.showMenu : true"
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div v-else key="secondary" class="pr-2">
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
state: JobState
|
||||
title: string
|
||||
rightText?: string
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
showMenu?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
runningNodeName?: string
|
||||
activeDetailsId?: string | null
|
||||
}>(),
|
||||
{
|
||||
workflowId: undefined,
|
||||
rightText: '',
|
||||
iconName: undefined,
|
||||
iconImageUrl: undefined,
|
||||
showClear: undefined,
|
||||
showMenu: undefined,
|
||||
progressTotalPercent: undefined,
|
||||
progressCurrentPercent: undefined,
|
||||
runningNodeName: undefined,
|
||||
activeDetailsId: null
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'menu', event: MouseEvent): void
|
||||
(e: 'view'): void
|
||||
(e: 'details-enter', jobId: string): void
|
||||
(e: 'details-leave', jobId: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
|
||||
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const rowRef = ref<HTMLDivElement | null>(null)
|
||||
const showDetails = computed(() => props.activeDetailsId === props.jobId)
|
||||
|
||||
const onRowEnter = () => {
|
||||
if (!isPreviewVisible.value) emit('details-enter', props.jobId)
|
||||
}
|
||||
const onRowLeave = () => emit('details-leave', props.jobId)
|
||||
const onPopoverEnter = () => emit('details-enter', props.jobId)
|
||||
const onPopoverLeave = () => emit('details-leave', props.jobId)
|
||||
|
||||
const isPreviewVisible = ref(false)
|
||||
const previewHideTimer = ref<number | null>(null)
|
||||
const previewShowTimer = ref<number | null>(null)
|
||||
const clearPreviewHideTimer = () => {
|
||||
if (previewHideTimer.value !== null) {
|
||||
clearTimeout(previewHideTimer.value)
|
||||
previewHideTimer.value = null
|
||||
}
|
||||
}
|
||||
const clearPreviewShowTimer = () => {
|
||||
if (previewShowTimer.value !== null) {
|
||||
clearTimeout(previewShowTimer.value)
|
||||
previewShowTimer.value = null
|
||||
}
|
||||
}
|
||||
const canShowPreview = computed(
|
||||
() => props.state === 'completed' && !!props.iconImageUrl
|
||||
)
|
||||
const scheduleShowPreview = () => {
|
||||
if (!canShowPreview.value) return
|
||||
clearPreviewHideTimer()
|
||||
clearPreviewShowTimer()
|
||||
previewShowTimer.value = window.setTimeout(() => {
|
||||
isPreviewVisible.value = true
|
||||
previewShowTimer.value = null
|
||||
}, 200)
|
||||
}
|
||||
const scheduleHidePreview = () => {
|
||||
clearPreviewHideTimer()
|
||||
clearPreviewShowTimer()
|
||||
previewHideTimer.value = window.setTimeout(() => {
|
||||
isPreviewVisible.value = false
|
||||
previewHideTimer.value = null
|
||||
}, 150)
|
||||
}
|
||||
const onIconEnter = () => scheduleShowPreview()
|
||||
const onIconLeave = () => scheduleHidePreview()
|
||||
const onPreviewEnter = () => scheduleShowPreview()
|
||||
const onPreviewLeave = () => scheduleHidePreview()
|
||||
|
||||
const popoverPosition = ref<{ top: number; right: number } | null>(null)
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const el = rowRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const gap = 8
|
||||
popoverPosition.value = {
|
||||
top: rect.top,
|
||||
right: window.innerWidth - rect.left + gap
|
||||
}
|
||||
}
|
||||
|
||||
const isAnyPopoverVisible = computed(
|
||||
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
|
||||
)
|
||||
|
||||
watch(
|
||||
isAnyPopoverVisible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
nextTick(updatePopoverPosition)
|
||||
} else {
|
||||
popoverPosition.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const isHovered = ref(false)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (props.iconName) return props.iconName
|
||||
return iconForJobState(props.state)
|
||||
})
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (props.showClear !== undefined) return props.showClear
|
||||
return props.state !== 'completed'
|
||||
})
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
|
||||
if (shouldShowMenu) emit('menu', event)
|
||||
}
|
||||
</script>
|
||||
83
src/components/queue/job/useJobErrorReporting.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type CopyHandler = (value: string) => void | Promise<void>
|
||||
|
||||
export type JobErrorDialogService = {
|
||||
showExecutionErrorDialog: (error: ExecutionErrorWsMessage) => void
|
||||
showErrorDialog: (
|
||||
error: Error,
|
||||
options?: {
|
||||
reportType?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
|
||||
type JobExecutionError = {
|
||||
detail?: ExecutionErrorWsMessage
|
||||
message: string
|
||||
}
|
||||
|
||||
export const extractExecutionError = (
|
||||
task: TaskItemImpl | null
|
||||
): JobExecutionError | null => {
|
||||
const status = (task as TaskItemImpl | null)?.status
|
||||
const messages = (status as { messages?: unknown[] } | undefined)?.messages
|
||||
if (!Array.isArray(messages) || !messages.length) return null
|
||||
const record = messages.find((entry: unknown) => {
|
||||
return Array.isArray(entry) && entry[0] === 'execution_error'
|
||||
}) as [string, ExecutionErrorWsMessage?] | undefined
|
||||
if (!record) return null
|
||||
const detail = record[1]
|
||||
const message = String(detail?.exception_message ?? '')
|
||||
return {
|
||||
detail,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
type UseJobErrorReportingOptions = {
|
||||
taskForJob: ComputedRef<TaskItemImpl | null>
|
||||
copyToClipboard: CopyHandler
|
||||
dialog: JobErrorDialogService
|
||||
}
|
||||
|
||||
export const useJobErrorReporting = ({
|
||||
taskForJob,
|
||||
copyToClipboard,
|
||||
dialog
|
||||
}: UseJobErrorReportingOptions) => {
|
||||
const errorMessageValue = computed(() => {
|
||||
const error = extractExecutionError(taskForJob.value)
|
||||
return error?.message ?? ''
|
||||
})
|
||||
|
||||
const copyErrorMessage = () => {
|
||||
if (errorMessageValue.value) {
|
||||
void copyToClipboard(errorMessageValue.value)
|
||||
}
|
||||
}
|
||||
|
||||
const reportJobError = () => {
|
||||
const error = extractExecutionError(taskForJob.value)
|
||||
if (error?.detail) {
|
||||
dialog.showExecutionErrorDialog(error.detail)
|
||||
return
|
||||
}
|
||||
if (errorMessageValue.value) {
|
||||
dialog.showErrorDialog(new Error(errorMessageValue.value), {
|
||||
reportType: 'queueJobError'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessageValue,
|
||||
copyErrorMessage,
|
||||
reportJobError
|
||||
}
|
||||
}
|
||||
230
src/components/queue/job/useQueueEstimates.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
|
||||
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
|
||||
import type { UseQueueEstimatesOptions } from './useQueueEstimates'
|
||||
|
||||
type QueueStore = UseQueueEstimatesOptions['queueStore']
|
||||
type ExecutionStore = UseQueueEstimatesOptions['executionStore']
|
||||
|
||||
const makeHistoryTask = (
|
||||
executionTimeInSeconds: number | string | undefined
|
||||
): TaskItemImpl =>
|
||||
({
|
||||
executionTimeInSeconds
|
||||
}) as TaskItemImpl
|
||||
|
||||
const makeRunningTask = (executionStartTimestamp?: number): TaskItemImpl =>
|
||||
({
|
||||
executionStartTimestamp
|
||||
}) as TaskItemImpl
|
||||
|
||||
const createQueueStore = (data?: Partial<QueueStore>): QueueStore =>
|
||||
({
|
||||
historyTasks: [],
|
||||
runningTasks: [],
|
||||
...data
|
||||
}) as QueueStore
|
||||
|
||||
const createExecutionStore = (data?: Partial<ExecutionStore>): ExecutionStore =>
|
||||
({
|
||||
runningWorkflowCount: 1,
|
||||
...data
|
||||
}) as ExecutionStore
|
||||
|
||||
type HarnessOptions = {
|
||||
queueStore?: QueueStore
|
||||
executionStore?: ExecutionStore
|
||||
task?: TaskItemImpl | null
|
||||
jobState?: JobState | null
|
||||
firstSeenTs?: number
|
||||
jobsAhead?: number | null
|
||||
now?: number
|
||||
}
|
||||
|
||||
const createHarness = (options?: HarnessOptions) => {
|
||||
const queueStore = options?.queueStore ?? createQueueStore()
|
||||
const executionStore = options?.executionStore ?? createExecutionStore()
|
||||
const taskRef = ref<TaskItemImpl | null>(options?.task ?? null)
|
||||
const jobStateRef = ref<JobState | null>(options?.jobState ?? null)
|
||||
const firstSeenRef = ref<number | undefined>(options?.firstSeenTs)
|
||||
const jobsAheadRef = ref<number | null>(options?.jobsAhead ?? null)
|
||||
const nowRef = ref(options?.now ?? 0)
|
||||
|
||||
const result = useQueueEstimates({
|
||||
queueStore,
|
||||
executionStore,
|
||||
taskForJob: computed(() => taskRef.value),
|
||||
jobState: computed(() => jobStateRef.value),
|
||||
firstSeenTs: computed(() => firstSeenRef.value),
|
||||
jobsAhead: computed(() => jobsAheadRef.value),
|
||||
nowTs: nowRef
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
queueStore,
|
||||
executionStore,
|
||||
taskRef,
|
||||
jobStateRef,
|
||||
firstSeenRef,
|
||||
jobsAheadRef,
|
||||
nowRef
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatElapsedTime', () => {
|
||||
it('formats elapsed milliseconds and clamps negatives to zero', () => {
|
||||
expect(formatElapsedTime(0)).toBe('0m 0s')
|
||||
expect(formatElapsedTime(61000)).toBe('1m 1s')
|
||||
expect(formatElapsedTime(90000)).toBe('1m 30s')
|
||||
expect(formatElapsedTime(-5000)).toBe('0m 0s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQueueEstimates', () => {
|
||||
it('only shows parallel queued stats for pending jobs seen with multiple runners', () => {
|
||||
const ready = createHarness({
|
||||
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
|
||||
jobState: 'pending',
|
||||
firstSeenTs: 1000
|
||||
})
|
||||
expect(ready.showParallelQueuedStats.value).toBe(true)
|
||||
|
||||
const missingTimestamp = createHarness({
|
||||
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
|
||||
jobState: 'pending'
|
||||
})
|
||||
expect(missingTimestamp.showParallelQueuedStats.value).toBe(false)
|
||||
|
||||
const singleRunner = createHarness({
|
||||
executionStore: createExecutionStore({ runningWorkflowCount: 1 }),
|
||||
jobState: 'pending',
|
||||
firstSeenTs: 1000
|
||||
})
|
||||
expect(singleRunner.showParallelQueuedStats.value).toBe(false)
|
||||
|
||||
const runningJob = createHarness({
|
||||
executionStore: createExecutionStore({ runningWorkflowCount: 3 }),
|
||||
jobState: 'running',
|
||||
firstSeenTs: 1000
|
||||
})
|
||||
expect(runningJob.showParallelQueuedStats.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the last 20 valid durations to estimate queued batches', () => {
|
||||
const durations = Array.from({ length: 25 }, (_, idx) => idx + 1)
|
||||
const queueStore = createQueueStore({
|
||||
historyTasks: [
|
||||
...durations.slice(0, 5).map((value) => makeHistoryTask(value)),
|
||||
makeHistoryTask('not-a-number'),
|
||||
makeHistoryTask(undefined),
|
||||
...durations.slice(5).map((value) => makeHistoryTask(value))
|
||||
]
|
||||
})
|
||||
|
||||
const { estimateRangeSeconds } = createHarness({
|
||||
queueStore,
|
||||
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
|
||||
jobsAhead: 5
|
||||
})
|
||||
|
||||
expect(estimateRangeSeconds.value).toEqual([47, 63])
|
||||
})
|
||||
|
||||
it('returns null for estimateRangeSeconds when no history or jobsAhead is unknown', () => {
|
||||
const emptyHistory = createHarness({
|
||||
queueStore: createQueueStore(),
|
||||
jobsAhead: 2
|
||||
})
|
||||
expect(emptyHistory.estimateRangeSeconds.value).toBeNull()
|
||||
|
||||
const missingAhead = createHarness({
|
||||
queueStore: createQueueStore({
|
||||
historyTasks: [makeHistoryTask(10)]
|
||||
})
|
||||
})
|
||||
expect(missingAhead.estimateRangeSeconds.value).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the running remaining range when there are no jobs ahead', () => {
|
||||
const now = 20000
|
||||
const queueStore = createQueueStore({
|
||||
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value)),
|
||||
runningTasks: [
|
||||
makeRunningTask(now - 5000),
|
||||
makeRunningTask(now - 15000),
|
||||
makeRunningTask(undefined)
|
||||
]
|
||||
})
|
||||
|
||||
const { estimateRangeSeconds } = createHarness({
|
||||
queueStore,
|
||||
jobsAhead: 0,
|
||||
now
|
||||
})
|
||||
|
||||
expect(estimateRangeSeconds.value).toEqual([5, 15])
|
||||
})
|
||||
|
||||
it('subtracts elapsed time when estimating a running job', () => {
|
||||
const now = 25000
|
||||
const queueStore = createQueueStore({
|
||||
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value))
|
||||
})
|
||||
|
||||
const { estimateRemainingRangeSeconds } = createHarness({
|
||||
queueStore,
|
||||
task: makeRunningTask(5000),
|
||||
jobState: 'running',
|
||||
firstSeenTs: 2000,
|
||||
now
|
||||
})
|
||||
|
||||
expect(estimateRemainingRangeSeconds.value).toEqual([0, 10])
|
||||
})
|
||||
|
||||
it('uses the first-seen timestamp for pending jobs and clamps negatives to zero', () => {
|
||||
const queueStore = createQueueStore({
|
||||
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value))
|
||||
})
|
||||
|
||||
const harness = createHarness({
|
||||
queueStore,
|
||||
jobState: 'pending',
|
||||
firstSeenTs: 10000,
|
||||
now: 25000
|
||||
})
|
||||
|
||||
expect(harness.estimateRemainingRangeSeconds.value).toEqual([5, 15])
|
||||
|
||||
harness.firstSeenRef.value = 1000
|
||||
harness.nowRef.value = 70000
|
||||
|
||||
expect(harness.estimateRemainingRangeSeconds.value).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('computes the elapsed label using execution start, then first-seen timestamp', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
harness.taskRef.value = makeRunningTask(1000)
|
||||
harness.jobStateRef.value = 'running'
|
||||
harness.nowRef.value = 4000
|
||||
|
||||
expect(harness.timeElapsedValue.value).toBe('0m 3s')
|
||||
|
||||
harness.jobStateRef.value = 'pending'
|
||||
harness.firstSeenRef.value = 2000
|
||||
harness.nowRef.value = 5000
|
||||
|
||||
expect(harness.timeElapsedValue.value).toBe('0m 3s')
|
||||
|
||||
harness.taskRef.value = null
|
||||
harness.firstSeenRef.value = undefined
|
||||
|
||||
expect(harness.timeElapsedValue.value).toBe('')
|
||||
})
|
||||
})
|
||||
149
src/components/queue/job/useQueueEstimates.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
|
||||
type QueueStore = ReturnType<typeof useQueueStore>
|
||||
type ExecutionStore = ReturnType<typeof useExecutionStore>
|
||||
|
||||
export type UseQueueEstimatesOptions = {
|
||||
queueStore: QueueStore
|
||||
executionStore: ExecutionStore
|
||||
taskForJob: ComputedRef<TaskItemImpl | null>
|
||||
jobState: ComputedRef<JobState | null>
|
||||
firstSeenTs: ComputedRef<number | undefined>
|
||||
jobsAhead: ComputedRef<number | null>
|
||||
nowTs: Ref<number>
|
||||
}
|
||||
|
||||
type EstimateRange = [number, number]
|
||||
|
||||
export const formatElapsedTime = (ms: number): string => {
|
||||
const totalSec = Math.max(0, Math.floor(ms / 1000))
|
||||
const minutes = Math.floor(totalSec / 60)
|
||||
const seconds = totalSec % 60
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
const pickRecentDurations = (queueStore: QueueStore) =>
|
||||
queueStore.historyTasks
|
||||
.map((task: TaskItemImpl) => Number(task.executionTimeInSeconds))
|
||||
.filter(
|
||||
(value: number | undefined) =>
|
||||
typeof value === 'number' && !Number.isNaN(value)
|
||||
) as number[]
|
||||
|
||||
export const useQueueEstimates = ({
|
||||
queueStore,
|
||||
executionStore,
|
||||
taskForJob,
|
||||
jobState,
|
||||
firstSeenTs,
|
||||
jobsAhead,
|
||||
nowTs
|
||||
}: UseQueueEstimatesOptions) => {
|
||||
const runningWorkflowCount = computed(
|
||||
() => executionStore.runningWorkflowCount
|
||||
)
|
||||
|
||||
const showParallelQueuedStats = computed(
|
||||
() =>
|
||||
jobState.value === 'pending' &&
|
||||
!!firstSeenTs.value &&
|
||||
(runningWorkflowCount.value ?? 0) > 1
|
||||
)
|
||||
|
||||
const recentDurations = computed<number[]>(() =>
|
||||
pickRecentDurations(queueStore).slice(-20)
|
||||
)
|
||||
|
||||
const runningRemainingRangeSeconds = computed<EstimateRange | null>(() => {
|
||||
const durations = recentDurations.value
|
||||
if (!durations.length) return null
|
||||
const sorted = durations.slice().sort((a, b) => a - b)
|
||||
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
|
||||
const p75 =
|
||||
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
|
||||
const running = queueStore.runningTasks as TaskItemImpl[]
|
||||
const now = nowTs.value
|
||||
const remaining = running
|
||||
.map((task) => task.executionStartTimestamp)
|
||||
.filter((timestamp): timestamp is number => typeof timestamp === 'number')
|
||||
.map((startTs) => {
|
||||
const elapsed = Math.max(0, Math.floor((now - startTs) / 1000))
|
||||
return {
|
||||
lo: Math.max(0, Math.round(avg - elapsed)),
|
||||
hi: Math.max(0, Math.round(p75 - elapsed))
|
||||
}
|
||||
})
|
||||
if (!remaining.length) return null
|
||||
const minLo = remaining.reduce(
|
||||
(min, range) => Math.min(min, range.lo),
|
||||
Infinity
|
||||
)
|
||||
const minHi = remaining.reduce(
|
||||
(min, range) => Math.min(min, range.hi),
|
||||
Infinity
|
||||
)
|
||||
return [minLo, minHi]
|
||||
})
|
||||
|
||||
const estimateRangeSeconds = computed<EstimateRange | null>(() => {
|
||||
const durations = recentDurations.value
|
||||
if (!durations.length) return null
|
||||
const ahead = jobsAhead.value
|
||||
if (ahead == null) return null
|
||||
const sorted = durations.slice().sort((a, b) => a - b)
|
||||
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
|
||||
const p75 =
|
||||
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
|
||||
if (ahead <= 0) {
|
||||
return runningRemainingRangeSeconds.value ?? [0, 0]
|
||||
}
|
||||
const runningCount = Math.max(1, runningWorkflowCount.value || 1)
|
||||
const batches = Math.ceil(ahead / runningCount)
|
||||
return [Math.round(avg * batches), Math.round(p75 * batches)]
|
||||
})
|
||||
|
||||
const estimateRemainingRangeSeconds = computed<EstimateRange | null>(() => {
|
||||
const durations = recentDurations.value
|
||||
if (!durations.length) return null
|
||||
const sorted = durations.slice().sort((a, b) => a - b)
|
||||
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
|
||||
const p75 =
|
||||
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
|
||||
const task = taskForJob.value as TaskItemImpl & {
|
||||
executionStartTimestamp?: number
|
||||
}
|
||||
const execStart =
|
||||
jobState.value === 'running' ? task?.executionStartTimestamp : undefined
|
||||
const baseTs = execStart ?? firstSeenTs.value
|
||||
const elapsed = baseTs
|
||||
? Math.max(0, Math.floor((nowTs.value - baseTs) / 1000))
|
||||
: 0
|
||||
const lo = Math.max(0, Math.round(avg - elapsed))
|
||||
const hi = Math.max(0, Math.round(p75 - elapsed))
|
||||
return [lo, hi]
|
||||
})
|
||||
|
||||
const timeElapsedValue = computed(() => {
|
||||
const task = taskForJob.value as TaskItemImpl & {
|
||||
executionStartTimestamp?: number
|
||||
}
|
||||
const execStart =
|
||||
jobState.value === 'running' ? task?.executionStartTimestamp : undefined
|
||||
const baseTs = execStart ?? firstSeenTs.value
|
||||
if (!baseTs) return ''
|
||||
return formatElapsedTime(nowTs.value - baseTs)
|
||||
})
|
||||
|
||||
return {
|
||||
runningWorkflowCount,
|
||||
showParallelQueuedStats,
|
||||
estimateRangeSeconds,
|
||||
estimateRemainingRangeSeconds,
|
||||
timeElapsedValue
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
@click.stop="handleNodes2ToggleClick"
|
||||
>
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
|
||||
<ToggleSwitch
|
||||
v-model="nodes2Enabled"
|
||||
class="ml-4"
|
||||
@@ -101,6 +102,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
|
||||
@@ -97,43 +97,62 @@
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="hasSelection"
|
||||
ref="footerRef"
|
||||
class="flex gap-1 h-18 w-full items-center justify-between"
|
||||
>
|
||||
<div ref="selectionCountButtonRef" class="flex-1 pl-4">
|
||||
<TextButton
|
||||
:label="
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
"
|
||||
type="transparent"
|
||||
@click="handleDeselectAll"
|
||||
/>
|
||||
<div class="flex-1 pl-4">
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<TextButton
|
||||
:label="
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
"
|
||||
type="transparent"
|
||||
:class="isCompact ? 'text-left' : ''"
|
||||
@click="handleDeselectAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pr-4">
|
||||
<IconTextButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
:label="$t('mediaAsset.selection.deleteSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<template v-if="isCompact">
|
||||
<!-- Compact mode: Icon only -->
|
||||
<IconButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
:label="$t('mediaAsset.selection.downloadSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<template #icon>
|
||||
</IconButton>
|
||||
<IconButton @click="handleDownloadSelected">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Normal mode: Icon + Text -->
|
||||
<IconTextButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
:label="$t('mediaAsset.selection.deleteSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
:label="$t('mediaAsset.selection.downloadSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -145,11 +164,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn, useElementHover } from '@vueuse/core'
|
||||
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
@@ -188,7 +208,7 @@ const shouldShowDeleteButton = computed(() => {
|
||||
|
||||
const getOutputCount = (item: AssetItem): number => {
|
||||
const count = item.user_metadata?.outputCount
|
||||
return typeof count === 'number' && count > 0 ? count : 0
|
||||
return typeof count === 'number' && count > 0 ? count : 1
|
||||
}
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
@@ -221,6 +241,22 @@ const {
|
||||
|
||||
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
const footerWidth = ref(0)
|
||||
|
||||
// Track footer width changes
|
||||
useResizeObserver(footerRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
footerWidth.value = entry.contentRect.width
|
||||
})
|
||||
|
||||
// Determine if we should show compact mode (icon only)
|
||||
// Threshold: 350px or less shows icon only
|
||||
const isCompact = computed(
|
||||
() => footerWidth.value > 0 && footerWidth.value <= 350
|
||||
)
|
||||
|
||||
// Hover state for selection count button
|
||||
const selectionCountButtonRef = ref<HTMLElement | null>(null)
|
||||
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
:value="allGalleryItems"
|
||||
:show-indicators="false"
|
||||
change-item-on-indicator-hover
|
||||
show-item-navigators
|
||||
:show-item-navigators="hasMultiple"
|
||||
full-screen
|
||||
circular
|
||||
:circular="hasMultiple"
|
||||
:show-thumbnails="false"
|
||||
:pt="{
|
||||
mask: {
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
@@ -61,6 +61,8 @@ const props = defineProps<{
|
||||
activeIndex: number
|
||||
}>()
|
||||
|
||||
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
|
||||
|
||||
let maskMouseDownTarget: EventTarget | null = null
|
||||
|
||||
const onMaskMouseDown = (event: MouseEvent) => {
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
v-if="showVueNodesBanner"
|
||||
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="icon-[lucide--rocket]"></i>
|
||||
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
|
||||
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
|
||||
<span class="pl-1.5 hidden md:inline">{{
|
||||
$t('vueNodesBanner.desc')
|
||||
}}</span>
|
||||
<Button
|
||||
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
|
||||
@click="handleTryItOut"
|
||||
|
||||
@@ -66,6 +66,14 @@ export function useSelectionToolboxPosition(
|
||||
lgCanvas.canvas
|
||||
)
|
||||
|
||||
// Unified dragging state - combines both LiteGraph and Vue node dragging
|
||||
const isDragging = computed((): boolean => {
|
||||
const litegraphDragging = canvasStore.canvas?.state?.draggingItems ?? false
|
||||
const vueNodeDragging =
|
||||
shouldRenderVueNodes.value && layoutStore.isDraggingVueNodes.value
|
||||
return litegraphDragging || vueNodeDragging
|
||||
})
|
||||
|
||||
/**
|
||||
* Update position based on selection
|
||||
*/
|
||||
@@ -77,6 +85,12 @@ export function useSelectionToolboxPosition(
|
||||
return
|
||||
}
|
||||
|
||||
// Don't show toolbox while dragging
|
||||
if (isDragging.value) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
|
||||
// Get bounds for all selected items
|
||||
@@ -241,14 +255,6 @@ export function useSelectionToolboxPosition(
|
||||
})
|
||||
}
|
||||
|
||||
// Unified dragging state - combines both LiteGraph and Vue node dragging
|
||||
const isDragging = computed((): boolean => {
|
||||
const litegraphDragging = canvasStore.canvas?.state?.draggingItems ?? false
|
||||
const vueNodeDragging =
|
||||
shouldRenderVueNodes.value && layoutStore.isDraggingVueNodes.value
|
||||
return litegraphDragging || vueNodeDragging
|
||||
})
|
||||
|
||||
watch(isDragging, handleDragStateChange)
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
id: string
|
||||
id: NodeId
|
||||
title: string
|
||||
type: string
|
||||
mode: number
|
||||
@@ -78,10 +79,64 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return function (widget) {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidWidgetValue(value: unknown): value is WidgetValue {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'object'
|
||||
)
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
|
||||
@@ -147,45 +202,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
linked: input.link != null
|
||||
})
|
||||
})
|
||||
return (
|
||||
node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -18,9 +19,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
|
||||
const { startSync } = useLayoutSync()
|
||||
|
||||
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
|
||||
@@ -40,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
|
||||
number,
|
||||
number
|
||||
]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Composable for managing widget value synchronization between Vue and LiteGraph
|
||||
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
|
||||
*/
|
||||
import { computed, toValue, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { MaybeRefOrGetter } from '@vueuse/core'
|
||||
|
||||
interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
|
||||
/** The widget configuration from LiteGraph */
|
||||
widget: SimplifiedWidget<T>
|
||||
/** The current value from parent component (can be a value or a getter function) */
|
||||
modelValue: MaybeRefOrGetter<T>
|
||||
/** Default value if modelValue is null/undefined */
|
||||
defaultValue: T
|
||||
/** Emit function from component setup */
|
||||
emit: (event: 'update:modelValue', value: T) => void
|
||||
/** Optional value transformer before sending to LiteGraph */
|
||||
transform?: (value: U) => T
|
||||
}
|
||||
|
||||
interface UseWidgetValueReturn<T extends WidgetValue = WidgetValue, U = T> {
|
||||
/** Local value for immediate UI updates */
|
||||
localValue: Ref<T>
|
||||
/** Handler for user interactions */
|
||||
onChange: (newValue: U) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages widget value synchronization with LiteGraph
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* const { localValue, onChange } = useWidgetValue({
|
||||
* widget: props.widget,
|
||||
* modelValue: props.modelValue,
|
||||
* defaultValue: ''
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue,
|
||||
emit,
|
||||
transform
|
||||
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
|
||||
// Ref for immediate UI feedback before value flows back through modelValue
|
||||
const newProcessedValue = ref<T | null>(null)
|
||||
|
||||
// Computed that prefers the immediately processed value, then falls back to modelValue
|
||||
const localValue = computed<T>(
|
||||
() => newProcessedValue.value ?? toValue(modelValue) ?? defaultValue
|
||||
)
|
||||
|
||||
// Clear newProcessedValue when modelValue updates (allowing external changes to flow through)
|
||||
watch(
|
||||
() => toValue(modelValue),
|
||||
() => {
|
||||
newProcessedValue.value = null
|
||||
}
|
||||
)
|
||||
|
||||
// Handle user changes
|
||||
const onChange = (newValue: U) => {
|
||||
// Handle different PrimeVue component signatures
|
||||
let processedValue: T
|
||||
if (transform) {
|
||||
processedValue = transform(newValue)
|
||||
} else {
|
||||
// Ensure type safety - only cast when types are compatible
|
||||
if (
|
||||
typeof newValue === typeof defaultValue ||
|
||||
newValue === null ||
|
||||
newValue === undefined
|
||||
) {
|
||||
processedValue = (newValue ?? defaultValue) as T
|
||||
} else {
|
||||
console.warn(
|
||||
`useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
|
||||
)
|
||||
processedValue = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// Set for immediate UI feedback
|
||||
newProcessedValue.value = processedValue
|
||||
|
||||
// Emit to parent component
|
||||
emit('update:modelValue', processedValue)
|
||||
}
|
||||
|
||||
return {
|
||||
localValue: localValue as Ref<T>,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for string widgets
|
||||
*/
|
||||
export function useStringWidgetValue(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string | (() => string),
|
||||
emit: (event: 'update:modelValue', value: string) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: '',
|
||||
emit,
|
||||
transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for number widgets
|
||||
*/
|
||||
export function useNumberWidgetValue(
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number | (() => number),
|
||||
emit: (event: 'update:modelValue', value: number) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: 0,
|
||||
emit,
|
||||
transform: (value: number | number[]) => {
|
||||
// Handle PrimeVue Slider which can emit number | number[]
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? (value[0] ?? 0) : 0
|
||||
}
|
||||
return Number(value) || 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for boolean widgets
|
||||
*/
|
||||
export function useBooleanWidgetValue(
|
||||
widget: SimplifiedWidget<boolean>,
|
||||
modelValue: boolean | (() => boolean),
|
||||
emit: (event: 'update:modelValue', value: boolean) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: false,
|
||||
emit,
|
||||
transform: (value: boolean) => Boolean(value)
|
||||
})
|
||||
}
|
||||
48
src/composables/maskeditor/useMaskEditor.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||
|
||||
export function useMaskEditor() {
|
||||
const openMaskEditor = (node: LGraphNode) => {
|
||||
if (!node) {
|
||||
console.error('[MaskEditor] No node provided')
|
||||
return
|
||||
}
|
||||
|
||||
if (!node.imgs?.length && node.previewMediaType !== 'image') {
|
||||
console.error('[MaskEditor] Node has no images')
|
||||
return
|
||||
}
|
||||
|
||||
useDialogStore().showDialog({
|
||||
key: 'global-mask-editor',
|
||||
headerComponent: TopBarHeader,
|
||||
component: MaskEditorContent,
|
||||
props: {
|
||||
node
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 90vw; height: 90vh;',
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
},
|
||||
header: {
|
||||
class: '!p-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
openMaskEditor
|
||||
}
|
||||
}
|
||||
@@ -1537,13 +1537,34 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-pro')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
} else if (model.includes('gemini-3-pro-preview')) {
|
||||
return '$0.002/$0.012 per 1K tokens'
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
GeminiImageNode: {
|
||||
displayPrice: '$0.03 per 1K tokens'
|
||||
displayPrice: '~$0.039/Image (1K)'
|
||||
},
|
||||
GeminiImage2Node: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!resolutionWidget) return 'Token-based'
|
||||
|
||||
const resolution = String(resolutionWidget.value)
|
||||
if (resolution.includes('1K')) {
|
||||
return '~$0.134/Image'
|
||||
} else if (resolution.includes('2K')) {
|
||||
return '~$0.134/Image'
|
||||
} else if (resolution.includes('4K')) {
|
||||
return '~$0.24/Image'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: {
|
||||
@@ -1827,6 +1848,7 @@ export const useNodePricing = () => {
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
GeminiImage2Node: ['resolution'],
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: ['model'],
|
||||
// ByteDance
|
||||
|
||||
116
src/composables/queue/useCompletionSummary.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
export type CompletionSummaryMode = 'allSuccess' | 'mixed' | 'allFailed'
|
||||
|
||||
export type CompletionSummary = {
|
||||
mode: CompletionSummaryMode
|
||||
completedCount: number
|
||||
failedCount: number
|
||||
thumbnailUrls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks queue activity transitions and exposes a short-lived summary of the
|
||||
* most recent generation batch.
|
||||
*/
|
||||
export const useCompletionSummary = () => {
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const isActive = computed(
|
||||
() => queueStore.runningTasks.length > 0 || !executionStore.isIdle
|
||||
)
|
||||
|
||||
const lastActiveStartTs = ref<number | null>(null)
|
||||
const _summary = ref<CompletionSummary | null>(null)
|
||||
const dismissTimer = ref<number | null>(null)
|
||||
|
||||
const clearDismissTimer = () => {
|
||||
if (dismissTimer.value !== null) {
|
||||
clearTimeout(dismissTimer.value)
|
||||
dismissTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const startDismissTimer = () => {
|
||||
clearDismissTimer()
|
||||
dismissTimer.value = window.setTimeout(() => {
|
||||
_summary.value = null
|
||||
dismissTimer.value = null
|
||||
}, 6000)
|
||||
}
|
||||
|
||||
const clearSummary = () => {
|
||||
_summary.value = null
|
||||
clearDismissTimer()
|
||||
}
|
||||
|
||||
watch(
|
||||
isActive,
|
||||
(active, prev) => {
|
||||
if (!prev && active) {
|
||||
lastActiveStartTs.value = Date.now()
|
||||
}
|
||||
if (prev && !active) {
|
||||
const start = lastActiveStartTs.value ?? 0
|
||||
const finished = queueStore.historyTasks.filter((t: any) => {
|
||||
const ts: number | undefined = 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
|
||||
}
|
||||
}
|
||||
352
src/composables/queue/useJobList.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import {
|
||||
dateKey,
|
||||
formatClockTime,
|
||||
formatShortMonthDay,
|
||||
isToday,
|
||||
isYesterday
|
||||
} from '@/utils/dateTimeUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
/** Tabs for job list filtering */
|
||||
export const jobTabs = ['All', 'Completed', 'Failed'] as const
|
||||
export type JobTab = (typeof jobTabs)[number]
|
||||
|
||||
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
|
||||
export type JobSortMode = (typeof jobSortModes)[number]
|
||||
|
||||
/**
|
||||
* UI item in the job list. Mirrors data previously prepared inline.
|
||||
*/
|
||||
export type JobListItem = {
|
||||
id: string
|
||||
title: string
|
||||
meta: string
|
||||
state: JobState
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
taskRef?: any
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
runningNodeName?: string
|
||||
executionTimeMs?: number
|
||||
computeHours?: number
|
||||
}
|
||||
|
||||
export type JobGroup = {
|
||||
key: string
|
||||
label: string
|
||||
items: JobListItem[]
|
||||
}
|
||||
|
||||
const ADDED_HINT_DURATION_MS = 3000
|
||||
const relativeTimeFormatterCache = new Map<string, Intl.RelativeTimeFormat>()
|
||||
const taskIdToKey = (id: string | number | undefined) => {
|
||||
if (id === null || id === undefined) return null
|
||||
const key = String(id)
|
||||
return key.length ? key : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns localized Today/Yesterday (capitalized) or localized Mon DD.
|
||||
*/
|
||||
const dateLabelForTimestamp = (
|
||||
ts: number,
|
||||
locale: string,
|
||||
relativeFormatter: Intl.RelativeTimeFormat
|
||||
) => {
|
||||
const formatRelativeDay = (value: number) => {
|
||||
const formatted = relativeFormatter.format(value, 'day')
|
||||
return formatted
|
||||
? formatted[0].toLocaleUpperCase(locale) + formatted.slice(1)
|
||||
: formatted
|
||||
}
|
||||
if (isToday(ts)) {
|
||||
return formatRelativeDay(0)
|
||||
}
|
||||
if (isYesterday(ts)) {
|
||||
return formatRelativeDay(-1)
|
||||
}
|
||||
return formatShortMonthDay(ts, locale)
|
||||
}
|
||||
|
||||
type TaskWithState = {
|
||||
task: TaskItemImpl
|
||||
state: JobState
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the reactive job list, filters, and grouped view for the queue overlay.
|
||||
*/
|
||||
export function useJobList() {
|
||||
const { t, locale } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
|
||||
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const clearAddedHintTimeout = (id: string) => {
|
||||
const timeoutId = addedHintTimeouts.get(id)
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
addedHintTimeouts.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleAddedHintExpiry = (id: string) => {
|
||||
clearAddedHintTimeout(id)
|
||||
const timeoutId = setTimeout(() => {
|
||||
addedHintTimeouts.delete(id)
|
||||
const updated = new Set(recentlyAddedPendingIds.value)
|
||||
if (updated.delete(id)) {
|
||||
recentlyAddedPendingIds.value = updated
|
||||
}
|
||||
}, ADDED_HINT_DURATION_MS)
|
||||
addedHintTimeouts.set(id, timeoutId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
queueStore.pendingTasks
|
||||
.map((task) => taskIdToKey(task.promptId))
|
||||
.filter((id): id is string => !!id),
|
||||
(pendingIds) => {
|
||||
const pendingSet = new Set(pendingIds)
|
||||
const next = new Set(recentlyAddedPendingIds.value)
|
||||
|
||||
pendingIds.forEach((id) => {
|
||||
if (!next.has(id)) {
|
||||
next.add(id)
|
||||
scheduleAddedHintExpiry(id)
|
||||
}
|
||||
})
|
||||
|
||||
for (const id of Array.from(next)) {
|
||||
if (!pendingSet.has(id)) {
|
||||
next.delete(id)
|
||||
clearAddedHintTimeout(id)
|
||||
}
|
||||
}
|
||||
|
||||
recentlyAddedPendingIds.value = next
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const shouldShowAddedHint = (task: TaskItemImpl, state: JobState) => {
|
||||
if (state !== 'pending') return false
|
||||
const id = taskIdToKey(task.promptId)
|
||||
if (!id) return false
|
||||
return recentlyAddedPendingIds.value.has(id)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
|
||||
addedHintTimeouts.clear()
|
||||
recentlyAddedPendingIds.value = new Set<string>()
|
||||
})
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
|
||||
const relativeTimeFormatter = computed(() => {
|
||||
const localeValue = locale.value
|
||||
let formatter = relativeTimeFormatterCache.get(localeValue)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.RelativeTimeFormat(localeValue, { numeric: 'auto' })
|
||||
relativeTimeFormatterCache.set(localeValue, formatter)
|
||||
}
|
||||
return formatter
|
||||
})
|
||||
const undatedLabel = computed(() => t('queue.jobList.undated') || 'Undated')
|
||||
|
||||
const isJobInitializing = (promptId: string | number | undefined) =>
|
||||
executionStore.isPromptInitializing(promptId)
|
||||
|
||||
const currentNodeName = computed(() => {
|
||||
const node = executionStore.executingNode
|
||||
if (!node) return t('g.emDash')
|
||||
const title = (node.title ?? '').toString().trim()
|
||||
if (title) return title
|
||||
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
|
||||
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
||||
return st(key, nodeType)
|
||||
})
|
||||
|
||||
const selectedJobTab = ref<JobTab>('All')
|
||||
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
|
||||
const selectedSortMode = ref<JobSortMode>('mostRecent')
|
||||
|
||||
const allTasksSorted = computed<TaskItemImpl[]>(() => {
|
||||
const all = [
|
||||
...queueStore.pendingTasks,
|
||||
...queueStore.runningTasks,
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
return all.sort((a, b) => b.queueIndex - a.queueIndex)
|
||||
})
|
||||
|
||||
const tasksWithJobState = computed<TaskWithState[]>(() =>
|
||||
allTasksSorted.value.map((task) => ({
|
||||
task,
|
||||
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
|
||||
}))
|
||||
)
|
||||
|
||||
const hasFailedJobs = computed(() =>
|
||||
tasksWithJobState.value.some(({ state }) => state === 'failed')
|
||||
)
|
||||
|
||||
watch(
|
||||
() => hasFailedJobs.value,
|
||||
(hasFailed) => {
|
||||
if (!hasFailed && selectedJobTab.value === 'Failed') {
|
||||
selectedJobTab.value = 'All'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const filteredTaskEntries = computed<TaskWithState[]>(() => {
|
||||
let entries = tasksWithJobState.value
|
||||
if (selectedJobTab.value === 'Completed') {
|
||||
entries = entries.filter(({ state }) => state === 'completed')
|
||||
} else if (selectedJobTab.value === 'Failed') {
|
||||
entries = entries.filter(({ state }) => state === 'failed')
|
||||
}
|
||||
|
||||
if (selectedWorkflowFilter.value === 'current') {
|
||||
const activeId = workflowStore.activeWorkflow?.activeState?.id
|
||||
if (!activeId) return []
|
||||
entries = entries.filter(({ task }) => {
|
||||
const wid = task.workflow?.id
|
||||
return !!wid && wid === activeId
|
||||
})
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
const filteredTasks = computed<TaskItemImpl[]>(() =>
|
||||
filteredTaskEntries.value.map(({ task }) => task)
|
||||
)
|
||||
|
||||
const jobItems = computed<JobListItem[]>(() => {
|
||||
return filteredTaskEntries.value.map(({ task, state }) => {
|
||||
const isActive =
|
||||
String(task.promptId ?? '') ===
|
||||
String(executionStore.activePromptId ?? '')
|
||||
const showAddedHint = shouldShowAddedHint(task, state)
|
||||
|
||||
const display = buildJobDisplay(task, state, {
|
||||
t,
|
||||
locale: locale.value,
|
||||
formatClockTimeFn: formatClockTime,
|
||||
isActive,
|
||||
totalPercent: isActive ? totalPercent.value : undefined,
|
||||
currentNodePercent: isActive ? currentNodePercent.value : undefined,
|
||||
currentNodeName: isActive ? currentNodeName.value : undefined,
|
||||
showAddedHint
|
||||
})
|
||||
|
||||
return {
|
||||
id: String(task.promptId),
|
||||
title: display.primary,
|
||||
meta: display.secondary,
|
||||
state,
|
||||
iconName: display.iconName,
|
||||
iconImageUrl: display.iconImageUrl,
|
||||
showClear: display.showClear,
|
||||
taskRef: task,
|
||||
progressTotalPercent:
|
||||
state === 'running' && isActive ? totalPercent.value : undefined,
|
||||
progressCurrentPercent:
|
||||
state === 'running' && isActive
|
||||
? currentNodePercent.value
|
||||
: undefined,
|
||||
runningNodeName:
|
||||
state === 'running' && isActive ? currentNodeName.value : undefined,
|
||||
executionTimeMs: task.executionTime,
|
||||
computeHours:
|
||||
task.executionTime !== undefined
|
||||
? task.executionTime / 3_600_000
|
||||
: undefined
|
||||
} as JobListItem
|
||||
})
|
||||
})
|
||||
|
||||
const jobItemById = computed(() => {
|
||||
const m = new Map<string, JobListItem>()
|
||||
jobItems.value.forEach((ji) => m.set(ji.id, ji))
|
||||
return m
|
||||
})
|
||||
|
||||
const groupedJobItems = computed<JobGroup[]>(() => {
|
||||
const groups: JobGroup[] = []
|
||||
const index = new Map<string, number>()
|
||||
const localeValue = locale.value
|
||||
for (const { task, state } of filteredTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
ts = task.executionEndTimestamp
|
||||
} else {
|
||||
ts = task.createTime
|
||||
}
|
||||
const key = ts === undefined ? 'undated' : dateKey(ts)
|
||||
let groupIdx = index.get(key)
|
||||
if (groupIdx === undefined) {
|
||||
const label =
|
||||
ts === undefined
|
||||
? undatedLabel.value
|
||||
: dateLabelForTimestamp(
|
||||
ts,
|
||||
localeValue,
|
||||
relativeTimeFormatter.value
|
||||
)
|
||||
groups.push({ key, label, items: [] })
|
||||
groupIdx = groups.length - 1
|
||||
index.set(key, groupIdx)
|
||||
}
|
||||
const ji = jobItemById.value.get(String(task.promptId))
|
||||
if (ji) groups[groupIdx].items.push(ji)
|
||||
}
|
||||
|
||||
if (selectedSortMode.value === 'totalGenerationTime') {
|
||||
const valueOrDefault = (value: JobListItem['executionTimeMs']) =>
|
||||
typeof value === 'number' && !Number.isNaN(value) ? value : -1
|
||||
const sortByExecutionTimeDesc = (a: JobListItem, b: JobListItem) =>
|
||||
valueOrDefault(b.executionTimeMs) - valueOrDefault(a.executionTimeMs)
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.items.sort(sortByExecutionTimeDesc)
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
return {
|
||||
// filters/state
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
// data sources
|
||||
allTasksSorted,
|
||||
filteredTasks,
|
||||
jobItems,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
}
|
||||
}
|
||||
356
src/composables/queue/useJobMenu.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { st, t } from '@/i18n'
|
||||
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
ResultItem,
|
||||
ResultItemType
|
||||
} from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
export type MenuEntry =
|
||||
| {
|
||||
kind?: 'item'
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
onClick?: () => void | Promise<void>
|
||||
}
|
||||
| { kind: 'divider'; key: string }
|
||||
|
||||
/**
|
||||
* Provides job context menu entries and actions.
|
||||
*
|
||||
* @param currentMenuItem Getter for the currently targeted job list item
|
||||
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
|
||||
*/
|
||||
export function useJobMenu(
|
||||
currentMenuItem: () => JobListItem | null,
|
||||
onInspectAsset?: (item: JobListItem) => void
|
||||
) {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const queueStore = useQueueStore()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const litegraphService = useLitegraphService()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const mediaAssetActions = useMediaAssetActions()
|
||||
|
||||
const openJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = item.taskRef?.workflow
|
||||
if (!data) return
|
||||
const filename = `Job ${item.id}.json`
|
||||
const temp = workflowStore.createTemporary(filename, data)
|
||||
await workflowService.openWorkflow(temp)
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
await copyToClipboard(item.id)
|
||||
}
|
||||
|
||||
const cancelJob = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
await api.interrupt(item.id)
|
||||
} else if (item.state === 'pending') {
|
||||
await api.deleteItem('queue', item.id)
|
||||
}
|
||||
await queueStore.update()
|
||||
}
|
||||
|
||||
const copyErrorMessage = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const msgs = item.taskRef?.status?.messages as any[] | undefined
|
||||
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
|
||||
| ExecutionErrorWsMessage
|
||||
| undefined
|
||||
const message = err?.exception_message
|
||||
if (message) await copyToClipboard(String(message))
|
||||
}
|
||||
|
||||
const reportError = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const msgs = item.taskRef?.status?.messages as any[] | undefined
|
||||
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
|
||||
| ExecutionErrorWsMessage
|
||||
| undefined
|
||||
if (err) useDialogService().showExecutionErrorDialog(err)
|
||||
}
|
||||
|
||||
// This is very magical only because it matches the respective backend implementation
|
||||
// There is or will be a better way to do this
|
||||
const addOutputLoaderNode = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
|
||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||
let widgetName: 'image' | 'file' | 'audio' | null = null
|
||||
if (result.isImage) {
|
||||
nodeType = 'LoadImage'
|
||||
widgetName = 'image'
|
||||
} else if (result.isVideo) {
|
||||
nodeType = 'LoadVideo'
|
||||
widgetName = 'file'
|
||||
} else if (result.isAudio) {
|
||||
nodeType = 'LoadAudio'
|
||||
widgetName = 'audio'
|
||||
}
|
||||
if (!nodeType || !widgetName) return
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
|
||||
if (!node) return
|
||||
|
||||
const isResultItemType = (v: string | undefined): v is ResultItemType =>
|
||||
v === 'input' || v === 'output' || v === 'temp'
|
||||
|
||||
const apiItem: ResultItem = {
|
||||
filename: result.filename,
|
||||
subfolder: result.subfolder,
|
||||
type: isResultItemType(result.type) ? result.type : undefined
|
||||
}
|
||||
|
||||
const annotated = createAnnotatedPath(apiItem, {
|
||||
rootFolder: apiItem.type
|
||||
})
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) {
|
||||
widget.value = annotated
|
||||
widget.callback?.(annotated)
|
||||
}
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
downloadFile(result.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = item.taskRef?.workflow
|
||||
if (!data) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let filename = `Job ${item.id}.json`
|
||||
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await useDialogService().prompt({
|
||||
title: t('workflowService.exportWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: filename
|
||||
})
|
||||
if (!input) return
|
||||
filename = appendJsonExt(input)
|
||||
}
|
||||
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
const deleteJobAsset = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const task = item.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
if (!task || !preview) return
|
||||
|
||||
const asset = mapTaskOutputToAssetItem(task, preview)
|
||||
const success = await mediaAssetActions.confirmDelete(asset)
|
||||
if (success) {
|
||||
await queueStore.update()
|
||||
}
|
||||
}
|
||||
|
||||
const removeFailedJob = async () => {
|
||||
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) return
|
||||
await queueStore.delete(task)
|
||||
}
|
||||
|
||||
const jobMenuOpenWorkflowLabel = computed(() =>
|
||||
st('queue.jobMenu.openAsWorkflowNewTab', 'Open as workflow in new tab')
|
||||
)
|
||||
const jobMenuOpenWorkflowFailedLabel = computed(() =>
|
||||
st('queue.jobMenu.openWorkflowNewTab', 'Open workflow in new tab')
|
||||
)
|
||||
const jobMenuCopyJobIdLabel = computed(() =>
|
||||
st('queue.jobMenu.copyJobId', 'Copy job ID')
|
||||
)
|
||||
const jobMenuCancelLabel = computed(() =>
|
||||
st('queue.jobMenu.cancelJob', 'Cancel job')
|
||||
)
|
||||
|
||||
const jobMenuEntries = computed<MenuEntry[]>(() => {
|
||||
const item = currentMenuItem()
|
||||
const state = item?.state
|
||||
if (!state) return []
|
||||
const hasDeletableAsset = !!item?.taskRef?.previewOutput
|
||||
if (state === 'completed') {
|
||||
return [
|
||||
{
|
||||
key: 'inspect-asset',
|
||||
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const item = currentMenuItem()
|
||||
if (item) onInspectAsset(item)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
{
|
||||
key: 'add-to-current',
|
||||
label: st(
|
||||
'queue.jobMenu.addToCurrentWorkflow',
|
||||
'Add to current workflow'
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
onClick: addOutputLoaderNode
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
onClick: downloadPreviewAsset
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{
|
||||
key: 'export-workflow',
|
||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||
icon: 'icon-[comfy--file-output]',
|
||||
onClick: exportJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasDeletableAsset
|
||||
? [
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: deleteJobAsset
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return [
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
},
|
||||
{
|
||||
key: 'copy-error',
|
||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyErrorMessage
|
||||
},
|
||||
{
|
||||
key: 'report-error',
|
||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||
icon: 'icon-[lucide--message-circle-warning]',
|
||||
onClick: reportError
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: removeFailedJob
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'cancel-job',
|
||||
label: jobMenuCancelLabel.value,
|
||||
icon: 'icon-[lucide--x]',
|
||||
onClick: cancelJob
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
jobMenuEntries,
|
||||
openJobWorkflow,
|
||||
copyJobId,
|
||||
cancelJob
|
||||
}
|
||||
}
|
||||
50
src/composables/queue/useQueueProgress.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
|
||||
|
||||
/**
|
||||
* Queue progress composable exposing total/current node progress values and styles.
|
||||
*/
|
||||
export function useQueueProgress() {
|
||||
const { locale } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const totalPercent = computed(() =>
|
||||
clampPercentInt(Math.round((executionStore.executionProgress ?? 0) * 100))
|
||||
)
|
||||
|
||||
const totalPercentFormatted = computed(() =>
|
||||
formatPercent0(locale.value, totalPercent.value)
|
||||
)
|
||||
|
||||
const currentNodePercent = computed(() =>
|
||||
clampPercentInt(
|
||||
Math.round((executionStore.executingNodeProgress ?? 0) * 100)
|
||||
)
|
||||
)
|
||||
|
||||
const currentNodePercentFormatted = computed(() =>
|
||||
formatPercent0(locale.value, currentNodePercent.value)
|
||||
)
|
||||
|
||||
const totalProgressStyle = computed(() => ({
|
||||
width: `${totalPercent.value}%`,
|
||||
background: 'var(--color-interface-panel-job-progress-primary)'
|
||||
}))
|
||||
|
||||
const currentNodeProgressStyle = computed(() => ({
|
||||
width: `${currentNodePercent.value}%`,
|
||||
background: 'var(--color-interface-panel-job-progress-secondary)'
|
||||
}))
|
||||
|
||||
return {
|
||||
totalPercent,
|
||||
totalPercentFormatted,
|
||||
currentNodePercent,
|
||||
currentNodePercentFormatted,
|
||||
totalProgressStyle,
|
||||
currentNodeProgressStyle
|
||||
}
|
||||
}
|
||||
32
src/composables/queue/useResultGallery.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* Manages result gallery state and activation for queue items.
|
||||
*/
|
||||
export function useResultGallery(getFilteredTasks: () => any[]) {
|
||||
const galleryActiveIndex = ref(-1)
|
||||
const galleryItems = shallowRef<ResultItemImpl[]>([])
|
||||
|
||||
const onViewItem = (item: JobListItem) => {
|
||||
const items: ResultItemImpl[] = getFilteredTasks().flatMap((t: any) => {
|
||||
const preview = t.previewOutput
|
||||
return preview && preview.supportsPreview ? [preview] : []
|
||||
})
|
||||
|
||||
if (!items.length) return
|
||||
|
||||
galleryItems.value = items
|
||||
const activeUrl: string | undefined = item.taskRef?.previewOutput?.url
|
||||
const idx = activeUrl ? items.findIndex((o) => o.url === activeUrl) : 0
|
||||
galleryActiveIndex.value = idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
return {
|
||||
galleryActiveIndex,
|
||||
galleryItems,
|
||||
onViewItem
|
||||
}
|
||||
}
|
||||
@@ -1219,6 +1219,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
|
||||
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
label: 'toggle linear mode',
|
||||
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
18
src/composables/useTooltipConfig.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Build a tooltip configuration object compatible with v-tooltip.
|
||||
* Consumers pass the translated text value.
|
||||
*/
|
||||
export const buildTooltipConfig = (value: string) => ({
|
||||
value,
|
||||
showDelay: 300,
|
||||
hideDelay: 0,
|
||||
pt: {
|
||||
text: {
|
||||
class:
|
||||
'border-node-component-tooltip-border bg-node-component-tooltip-surface text-node-component-tooltip border rounded-md px-2 py-1 text-xs leading-none shadow-none'
|
||||
},
|
||||
arrow: {
|
||||
class: 'border-t-node-component-tooltip-border'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -14,7 +14,6 @@ export const CORE_MENU_COMMANDS = [
|
||||
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
|
||||
[['Edit'], ['Comfy.ClearWorkflow']],
|
||||
[['Edit'], ['Comfy.OpenClipspace']],
|
||||
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
|
||||
[
|
||||
['Edit'],
|
||||
[
|
||||
|
||||
@@ -5,10 +5,9 @@ import { app } from '@/scripts/app'
|
||||
import { ComfyApp } from '@/scripts/app'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||
import { MaskEditorDialogOld } from './maskEditorOld'
|
||||
import { ClipspaceDialog } from './clipspace'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
|
||||
function openMaskEditor(node: LGraphNode): void {
|
||||
if (!node) {
|
||||
@@ -26,32 +25,7 @@ function openMaskEditor(node: LGraphNode): void {
|
||||
)
|
||||
|
||||
if (useNewEditor) {
|
||||
// Use new refactored editor
|
||||
useDialogStore().showDialog({
|
||||
key: 'global-mask-editor',
|
||||
headerComponent: TopBarHeader,
|
||||
component: MaskEditorContent,
|
||||
props: {
|
||||
node
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 90vw; height: 90vh;',
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
},
|
||||
header: {
|
||||
class: '!p-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
} else {
|
||||
// Use old editor
|
||||
ComfyApp.copyToClipspace(node)
|
||||
|
||||
@@ -17,14 +17,17 @@ useExtensionService().registerExtension({
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
|
||||
|
||||
const showValueWidget = ComfyWidgets['STRING'](
|
||||
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
||||
this,
|
||||
'preview',
|
||||
['STRING', { multiline: true }],
|
||||
['MARKDOWN', {}],
|
||||
app
|
||||
).widget as DOMWidget<any, any>
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
showValueWidget.options.read_only = true
|
||||
|
||||
showValueWidget.element.readOnly = true
|
||||
showValueWidget.element.disabled = true
|
||||
|
||||
showValueWidget.serialize = false
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export type {
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
export type rendererType = 'LG' | 'Vue'
|
||||
export type RendererType = 'LG' | 'Vue'
|
||||
|
||||
export interface LGraphState {
|
||||
lastGroupId: number
|
||||
@@ -106,7 +106,7 @@ export interface LGraphExtra extends Dictionary<unknown> {
|
||||
reroutes?: SerialisableReroute[]
|
||||
linkExtensions?: { id: number; parentId: number | undefined }[]
|
||||
ds?: DragAndScaleState
|
||||
workflowRendererVersion?: rendererType
|
||||
workflowRendererVersion?: RendererType
|
||||
}
|
||||
|
||||
export interface BaseLGraph {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -1771,18 +1772,19 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
static onMenuNodeClone(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
|
||||
const nodes = canvas.selectedItems.size ? [...canvas.selectedItems] : [node]
|
||||
if (nodes.length) LGraphCanvas.cloneNodes(nodes)
|
||||
}
|
||||
|
||||
static cloneNodes(nodes: Positionable[]) {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
|
||||
// Find top-left-most boundary
|
||||
let offsetX = Infinity
|
||||
@@ -1792,11 +1794,11 @@ export class LGraphCanvas
|
||||
throw new TypeError(
|
||||
'Invalid node encountered on clone. `pos` was null.'
|
||||
)
|
||||
if (item.pos[0] < offsetX) offsetX = item.pos[0]
|
||||
if (item.pos[1] < offsetY) offsetY = item.pos[1]
|
||||
offsetX = Math.min(offsetX, item.pos[0])
|
||||
offsetY = Math.min(offsetY, item.pos[1])
|
||||
}
|
||||
|
||||
canvas._deserializeItems(canvas._serializeItems(nodes), {
|
||||
return canvas._deserializeItems(canvas._serializeItems(nodes), {
|
||||
position: [offsetX + 5, offsetY + 5]
|
||||
})
|
||||
}
|
||||
@@ -4042,16 +4044,25 @@ export class LGraphCanvas
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: node.size?.[1] ?? 200
|
||||
}
|
||||
}))
|
||||
const newPositions = created
|
||||
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
||||
.map((node) => {
|
||||
const fullHeight = node.size?.[1] ?? 200
|
||||
const layoutHeight = LiteGraph.vueNodesMode
|
||||
? removeNodeTitleHeight(fullHeight)
|
||||
: fullHeight
|
||||
return {
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: layoutHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
this.selectItems(created)
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "تكرار",
|
||||
"enterNewName": "أدخل اسمًا جديدًا"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "إلغاء",
|
||||
"cancelEditTooltip": "إلغاء التعديل",
|
||||
"copiedTooltip": "تم النسخ",
|
||||
"copyTooltip": "نسخ الرسالة إلى الحافظة",
|
||||
"editTooltip": "تعديل الرسالة"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "فشل النسخ إلى الحافظة",
|
||||
"errorNotSupported": "API الحافظة غير مدعوم في متصفحك",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "عارض ثلاثي الأبعاد (بيتا)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "يتطلب ComfyUI {version}:",
|
||||
"missingNodesDescription": "عند تحميل الرسم البياني، لم يتم العثور على العقد التالية.\nقد يحدث هذا أيضًا إذا كانت إصدار التثبيت المثبت أقل وأن هذا النوع من العقد غير موجود.",
|
||||
"missingNodesTitle": "بعض العقد مفقودة",
|
||||
"outdatedVersion": "بعض العقد تتطلب إصدار أحدث من ComfyUI (الحالي: {version}). يرجى التحديث لاستخدام جميع العقد.",
|
||||
"outdatedVersionGeneric": "بعض العقد تتطلب إصدار أحدث من ComfyUI. يرجى التحديث لاستخدام جميع العقد."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "لا شيء",
|
||||
"OK": "حسنًا",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "الإصدار"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "تطبيق على كامل الصورة",
|
||||
"Brush Settings": "إعدادات الفرشاة",
|
||||
"Brush Shape": "شكل الفرشاة",
|
||||
"Clear": "مسح",
|
||||
"Color Select Settings": "إعدادات اختيار اللون",
|
||||
"Fill Opacity": "شفافية التعبئة",
|
||||
"Hardness": "الصلابة",
|
||||
"Image Layer": "طبقة الصورة",
|
||||
"Invert": "عكس",
|
||||
"Layers": "الطبقات",
|
||||
"Live Preview": "معاينة حية",
|
||||
"Mask Layer": "طبقة القناع",
|
||||
"Mask Opacity": "شفافية القناع",
|
||||
"Mask Tolerance": "تسامح القناع",
|
||||
"Method": "الطريقة",
|
||||
"Opacity": "الشفافية",
|
||||
"Paint Bucket Settings": "إعدادات دلو الطلاء",
|
||||
"Reset to Default": "إعادة إلى الافتراضي",
|
||||
"Selection Opacity": "شفافية التحديد",
|
||||
"Smoothing Precision": "دقة التنعيم",
|
||||
"Stop at mask": "التوقف عند القناع",
|
||||
"Thickness": "السماكة",
|
||||
"Tolerance": "التسامح"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "تم حذف الأصل بنجاح",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "أداء اللوحة",
|
||||
"Canvas Toggle Lock": "تبديل قفل اللوحة",
|
||||
"Check for Custom Node Updates": "التحقق من تحديثات العقد المخصصة",
|
||||
"Check for Updates": "التحقق من التحديثات",
|
||||
"Clear Pending Tasks": "مسح المهام المعلقة",
|
||||
"Clear Workflow": "مسح سير العمل",
|
||||
"Clipspace": "مساحة القص",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "مدير العقد المخصصة",
|
||||
"Decrease Brush Size in MaskEditor": "تقليل حجم الفرشاة في محرر القناع",
|
||||
"Delete Selected Items": "حذف العناصر المحددة",
|
||||
"Desktop User Guide": "دليل المستخدم لسطح المكتب",
|
||||
"Duplicate Current Workflow": "نسخ سير العمل الحالي",
|
||||
"Edit": "تحرير",
|
||||
"Edit Subgraph Widgets": "تحرير عناصر واجهة المستخدم للرسم البياني الفرعي",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "روابط العقد",
|
||||
"Open": "فتح",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "فتح عارض ثلاثي الأبعاد (بيتا) للعقدة المحددة",
|
||||
"Open Custom Nodes Folder": "فتح مجلد العقد المخصصة",
|
||||
"Open DevTools": "فتح أدوات المطور",
|
||||
"Open Inputs Folder": "فتح مجلد المدخلات",
|
||||
"Open Logs Folder": "فتح مجلد السجلات",
|
||||
"Open Mask Editor for Selected Node": "فتح محرر القناع للعقدة المحددة",
|
||||
"Open Models Folder": "فتح مجلد النماذج",
|
||||
"Open Outputs Folder": "فتح مجلد المخرجات",
|
||||
"Open Sign In Dialog": "فتح نافذة تسجيل الدخول",
|
||||
"Open extra_model_paths_yaml": "فتح ملف extra_model_paths.yaml",
|
||||
"Pin/Unpin Selected Items": "تثبيت/إلغاء تثبيت العناصر المحددة",
|
||||
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
|
||||
"Previous Opened Workflow": "سير العمل السابق المفتوح",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "قائمة انتظار التعليمات",
|
||||
"Queue Prompt (Front)": "قائمة انتظار التعليمات (أمامي)",
|
||||
"Queue Selected Output Nodes": "قائمة انتظار عقد المخرجات المحددة",
|
||||
"Quit": "خروج",
|
||||
"Redo": "إعادة",
|
||||
"Refresh Node Definitions": "تحديث تعريفات العقد",
|
||||
"Reinstall": "إعادة التثبيت",
|
||||
"Reset View": "إعادة تعيين العرض",
|
||||
"Resize Selected Nodes": "تغيير حجم العقد المحددة",
|
||||
"Restart": "إعادة التشغيل",
|
||||
"Save": "حفظ",
|
||||
"Save As": "حفظ باسم",
|
||||
"Show Keybindings Dialog": "عرض مربع حوار اختصارات لوحة المفاتيح",
|
||||
|
||||
@@ -1,40 +1,4 @@
|
||||
{
|
||||
"Comfy-Desktop_CheckForUpdates": {
|
||||
"label": "Check for Updates"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
|
||||
"label": "Open Custom Nodes Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenInputsFolder": {
|
||||
"label": "Open Inputs Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenLogsFolder": {
|
||||
"label": "Open Logs Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelConfig": {
|
||||
"label": "Open extra_model_paths.yaml"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelsFolder": {
|
||||
"label": "Open Models Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenOutputsFolder": {
|
||||
"label": "Open Outputs Folder"
|
||||
},
|
||||
"Comfy-Desktop_OpenDevTools": {
|
||||
"label": "Open DevTools"
|
||||
},
|
||||
"Comfy-Desktop_OpenUserGuide": {
|
||||
"label": "Desktop User Guide"
|
||||
},
|
||||
"Comfy-Desktop_Quit": {
|
||||
"label": "Quit"
|
||||
},
|
||||
"Comfy-Desktop_Reinstall": {
|
||||
"label": "Reinstall"
|
||||
},
|
||||
"Comfy-Desktop_Restart": {
|
||||
"label": "Restart"
|
||||
},
|
||||
"Comfy_3DViewer_Open3DViewer": {
|
||||
"label": "Open 3D Viewer (Beta) for Selected Node"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"g": {
|
||||
"beta": "Beta",
|
||||
"user": "User",
|
||||
"currentUser": "Current user",
|
||||
"empty": "Empty",
|
||||
@@ -71,6 +72,7 @@
|
||||
"info": "Node Info",
|
||||
"bookmark": "Save to Library",
|
||||
"moreOptions": "More Options",
|
||||
"more": "More",
|
||||
"loading": "Loading",
|
||||
"loadingPanel": "Loading {panel} panel...",
|
||||
"preview": "PREVIEW",
|
||||
@@ -170,6 +172,13 @@
|
||||
"inProgress": "In progress",
|
||||
"completed": "Completed",
|
||||
"interrupted": "Interrupted",
|
||||
"queued": "Queued",
|
||||
"running": "Running",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"job": "Job",
|
||||
"untitled": "Untitled",
|
||||
"emDash": "—",
|
||||
"enabling": "Enabling {id}",
|
||||
"disabling": "Disabling {id}",
|
||||
"updating": "Updating {id}",
|
||||
@@ -683,6 +692,40 @@
|
||||
"hideCanceled": "Hide Canceled"
|
||||
}
|
||||
},
|
||||
"queueProgressOverlay": {
|
||||
"title": "Queue Progress",
|
||||
"total": "Total: {percent}",
|
||||
"colonPercent": ": {percent}",
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"running": "running",
|
||||
"interruptAll": "Interrupt all running jobs",
|
||||
"moreOptions": "More options",
|
||||
"showAssets": "Show assets",
|
||||
"showAssetsPanel": "Show assets panel",
|
||||
"queuedSuffix": "queued",
|
||||
"clearQueued": "Clear queued",
|
||||
"clearHistory": "Clear job queue history",
|
||||
"filterJobs": "Filter jobs",
|
||||
"filterBy": "Filter by",
|
||||
"filterAllWorkflows": "All workflows",
|
||||
"filterCurrentWorkflow": "Current workflow",
|
||||
"sortJobs": "Sort jobs",
|
||||
"sortBy": "Sort by",
|
||||
"activeJobsSuffix": "active jobs",
|
||||
"jobQueue": "Job Queue",
|
||||
"expandCollapsedQueue": "Expand job queue",
|
||||
"viewJobHistory": "View job history",
|
||||
"noActiveJobs": "No active jobs",
|
||||
"stubClipTextEncode": "CLIP Text Encode:",
|
||||
"jobsCompleted": "{count} job completed | {count} jobs completed",
|
||||
"jobsFailed": "{count} job failed | {count} jobs failed",
|
||||
"cancelJobTooltip": "Cancel job",
|
||||
"clearQueueTooltip": "Clear queue",
|
||||
"clearHistoryDialogTitle": "Clear your job queue history?",
|
||||
"clearHistoryDialogDescription": "All the finished or failed jobs below will be removed from this Job queue panel.",
|
||||
"clearHistoryDialogAssetsNote": "Assets generated by these jobs won’t be deleted and can always be viewed from the assets panel."
|
||||
},
|
||||
"workflowTab": {
|
||||
"confirmDeleteTitle": "Delete workflow?",
|
||||
"confirmDelete": "Are you sure you want to delete this workflow?",
|
||||
@@ -737,6 +780,7 @@
|
||||
"onChangeTooltip": "The workflow will be queued once a change is made",
|
||||
"runWorkflow": "Run workflow (Shift to queue at front)",
|
||||
"runWorkflowFront": "Run workflow (Queue at front)",
|
||||
"runWorkflowDisabled": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow.",
|
||||
"run": "Run",
|
||||
"execute": "Execute",
|
||||
"interrupt": "Cancel current run",
|
||||
@@ -940,23 +984,60 @@
|
||||
"help": "Help",
|
||||
"queue": "Queue Panel"
|
||||
},
|
||||
"queue": {
|
||||
"initializingAlmostReady": "Initializing - Almost ready",
|
||||
"inQueue": "In queue...",
|
||||
"jobAddedToQueue": "Job added to queue",
|
||||
"jobMenu": {
|
||||
"openAsWorkflowNewTab": "Open as workflow in new tab",
|
||||
"openWorkflowNewTab": "Open workflow in new tab",
|
||||
"copyJobId": "Copy job ID",
|
||||
"cancelJob": "Cancel job",
|
||||
"inspectAsset": "Inspect asset",
|
||||
"addToCurrentWorkflow": "Add to current workflow",
|
||||
"download": "Download",
|
||||
"exportWorkflow": "Export workflow",
|
||||
"delete": "Delete",
|
||||
"deleteAsset": "Delete asset",
|
||||
"removeJob": "Remove job",
|
||||
"copyErrorMessage": "Copy error message",
|
||||
"reportError": "Report error"
|
||||
},
|
||||
"jobList": {
|
||||
"undated": "Undated",
|
||||
"sortMostRecent": "Most recent",
|
||||
"sortTotalGenerationTime": "Total generation time (longest first)",
|
||||
"sortComputeHoursUsed": "Compute hours used (most first)"
|
||||
},
|
||||
"jobDetails": {
|
||||
"header": "Job Details",
|
||||
"workflow": "Workflow",
|
||||
"jobId": "Job ID",
|
||||
"queuedAt": "Queued at",
|
||||
"queuePosition": "Queue position",
|
||||
"timeElapsed": "Time elapsed",
|
||||
"estimatedStartIn": "Estimated to start in",
|
||||
"estimatedFinishIn": "Estimated to finish in",
|
||||
"generatedOn": "Generated on",
|
||||
"totalGenerationTime": "Total generation time",
|
||||
"computeHoursUsed": "Compute hours used",
|
||||
"failedAfter": "Failed after",
|
||||
"errorMessage": "Error message",
|
||||
"report": "Report",
|
||||
"queuePositionValue": "~{count} job ahead of yours | ~{count} jobs ahead of yours",
|
||||
"eta": {
|
||||
"seconds": "~{count} second | ~{count} seconds",
|
||||
"secondsRange": "~{lo}-{hi} seconds",
|
||||
"minutes": "~{count} minute | ~{count} minutes",
|
||||
"minutesRange": "~{lo}-{hi} minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"menuLabels": {
|
||||
"File": "File",
|
||||
"Edit": "Edit",
|
||||
"View": "View",
|
||||
"Help": "Help",
|
||||
"Check for Updates": "Check for Updates",
|
||||
"Open Custom Nodes Folder": "Open Custom Nodes Folder",
|
||||
"Open Inputs Folder": "Open Inputs Folder",
|
||||
"Open Logs Folder": "Open Logs Folder",
|
||||
"Open extra_model_paths_yaml": "Open extra_model_paths.yaml",
|
||||
"Open Models Folder": "Open Models Folder",
|
||||
"Open Outputs Folder": "Open Outputs Folder",
|
||||
"Open DevTools": "Open DevTools",
|
||||
"Desktop User Guide": "Desktop User Guide",
|
||||
"Quit": "Quit",
|
||||
"Reinstall": "Reinstall",
|
||||
"Restart": "Restart",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
|
||||
"Experimental: Browse Model Assets": "Experimental: Browse Model Assets",
|
||||
"Browse Templates": "Browse Templates",
|
||||
@@ -1335,6 +1416,7 @@
|
||||
"Tripo": "Tripo",
|
||||
"Veo": "Veo",
|
||||
"Vidu": "Vidu",
|
||||
"": "",
|
||||
"camera": "camera",
|
||||
"Wan": "Wan"
|
||||
},
|
||||
@@ -1836,7 +1918,8 @@
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"deleteBlueprint": "Delete Blueprint",
|
||||
"enterNewName": "Enter new name"
|
||||
"enterNewName": "Enter new name",
|
||||
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red)."
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Shortcuts",
|
||||
@@ -1993,7 +2076,36 @@
|
||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"searchAssetsPlaceholder": "Type to search...",
|
||||
"uploadModel": "Upload model",
|
||||
"uploadModel": "Import model",
|
||||
"uploadModelFromCivitai": "Import a model from Civitai",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: 1 GB",
|
||||
"civitaiLinkLabel": "Civitai model download link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
"upload": "Import",
|
||||
"uploadingModel": "Importing model...",
|
||||
"uploadSuccess": "Model imported successfully!",
|
||||
"uploadFailed": "Import failed",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"modelTypeSelectorLabel": "What type of model is this?",
|
||||
"modelTypeSelectorPlaceholder": "Select model type",
|
||||
"selectModelType": "Select model type",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"modelUploaded": "Model imported! 🎉",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"allModels": "All Models",
|
||||
"allCategory": "All {category}",
|
||||
"unknown": "Unknown",
|
||||
@@ -2005,6 +2117,13 @@
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
|
||||
"errorModelTypeNotSupported": "This model type is not supported",
|
||||
"errorUnknown": "An unexpected error occurred",
|
||||
"errorUploadFailed": "Failed to import asset. Please try again.",
|
||||
"ariaLabel": {
|
||||
"assetCard": "{name} - {type} asset",
|
||||
"loadingAsset": "Loading asset"
|
||||
@@ -2022,6 +2141,11 @@
|
||||
"assetDeletedSuccessfully": "Asset deleted successfully",
|
||||
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
|
||||
"failedToDeleteAsset": "Failed to delete asset",
|
||||
"actions": {
|
||||
"inspect": "Inspect",
|
||||
"more": "More options",
|
||||
"seeMoreOutputs": "See more outputs"
|
||||
},
|
||||
"jobIdToast": {
|
||||
"jobIdCopied": "Job ID copied to clipboard",
|
||||
"jobIdCopyFailed": "Failed to copy Job ID",
|
||||
@@ -2064,7 +2188,8 @@
|
||||
}
|
||||
},
|
||||
"vueNodesBanner": {
|
||||
"message": "Introducing Nodes 2.0 – More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"title": "Introducing Nodes 2.0",
|
||||
"desc": "– More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"tryItOut": "Try it out"
|
||||
},
|
||||
"vueNodesMigration": {
|
||||
@@ -2074,6 +2199,10 @@
|
||||
"vueNodesMigrationMainMenu": {
|
||||
"message": "Switch back to Nodes 2.0 anytime from the main menu."
|
||||
},
|
||||
"linearMode": {
|
||||
"share": "Share",
|
||||
"openWorkflow": "Open Workflow"
|
||||
},
|
||||
"missingNodes": {
|
||||
"cloud": {
|
||||
"title": "These nodes aren't available on Comfy Cloud yet",
|
||||
|
||||
@@ -2570,7 +2570,7 @@
|
||||
}
|
||||
},
|
||||
"GeminiImageNode": {
|
||||
"display_name": "Google Gemini Image",
|
||||
"display_name": "Nano Banana (Google Gemini Image)",
|
||||
"description": "Edit images synchronously via Google API.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
@@ -12770,6 +12770,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wanBlockSwap": {
|
||||
"display_name": "wanBlockSwap",
|
||||
"description": "NOP",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanCameraEmbedding": {
|
||||
"display_name": "WanCameraEmbedding",
|
||||
"inputs": {
|
||||
|
||||
@@ -1,30 +1,4 @@
|
||||
{
|
||||
"Comfy-Desktop_AutoUpdate": {
|
||||
"name": "Automatically check for updates"
|
||||
},
|
||||
"Comfy-Desktop_SendStatistics": {
|
||||
"name": "Send anonymous usage metrics"
|
||||
},
|
||||
"Comfy-Desktop_UV_PypiInstallMirror": {
|
||||
"name": "Pypi Install Mirror",
|
||||
"tooltip": "Default pip install mirror"
|
||||
},
|
||||
"Comfy-Desktop_UV_PythonInstallMirror": {
|
||||
"name": "Python Install Mirror",
|
||||
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme."
|
||||
},
|
||||
"Comfy-Desktop_UV_TorchInstallMirror": {
|
||||
"name": "Torch Install Mirror",
|
||||
"tooltip": "Pip install mirror for pytorch"
|
||||
},
|
||||
"Comfy-Desktop_WindowStyle": {
|
||||
"name": "Window Style",
|
||||
"tooltip": "Custom: Replace the system title bar with ComfyUI's Top menu",
|
||||
"options": {
|
||||
"default": "default",
|
||||
"custom": "custom"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "Canvas background image",
|
||||
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "Duplicar",
|
||||
"enterNewName": "Ingrese un nuevo nombre"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "Cancelar",
|
||||
"cancelEditTooltip": "Cancelar edición",
|
||||
"copiedTooltip": "Copiado",
|
||||
"copyTooltip": "Copiar mensaje al portapapeles",
|
||||
"editTooltip": "Editar mensaje"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Error al copiar al portapapeles",
|
||||
"errorNotSupported": "API del portapapeles no soportada en su navegador",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "Visor 3D (Beta)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
|
||||
"missingNodesDescription": "Al cargar el grafo, no se encontraron los siguientes tipos de nodos.\nEsto también puede ocurrir si tu versión instalada es anterior y ese tipo de nodo no se puede encontrar.",
|
||||
"missingNodesTitle": "Faltan Algunos Nodos",
|
||||
"outdatedVersion": "Algunos nodos requieren una versión más reciente de ComfyUI (actual: {version}). Por favor, actualiza para usar todos los nodos.",
|
||||
"outdatedVersionGeneric": "Algunos nodos requieren una versión más reciente de ComfyUI. Por favor, actualiza para usar todos los nodos."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Ninguno",
|
||||
"OK": "OK",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "Versión"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "Aplicar a toda la imagen",
|
||||
"Brush Settings": "Configuración de pincel",
|
||||
"Brush Shape": "Forma de pincel",
|
||||
"Clear": "Borrar",
|
||||
"Color Select Settings": "Configuración de selección de color",
|
||||
"Fill Opacity": "Opacidad de relleno",
|
||||
"Hardness": "Dureza",
|
||||
"Image Layer": "Capa de imagen",
|
||||
"Invert": "Invertir",
|
||||
"Layers": "Capas",
|
||||
"Live Preview": "Vista previa en vivo",
|
||||
"Mask Layer": "Capa de máscara",
|
||||
"Mask Opacity": "Opacidad de máscara",
|
||||
"Mask Tolerance": "Tolerancia de máscara",
|
||||
"Method": "Método",
|
||||
"Opacity": "Opacidad",
|
||||
"Paint Bucket Settings": "Configuración de cubo de pintura",
|
||||
"Reset to Default": "Restablecer a predeterminado",
|
||||
"Selection Opacity": "Opacidad de selección",
|
||||
"Smoothing Precision": "Precisión de suavizado",
|
||||
"Stop at mask": "Detener en máscara",
|
||||
"Thickness": "Grosor",
|
||||
"Tolerance": "Tolerancia"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "Recurso eliminado exitosamente",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "Rendimiento del Lienzo",
|
||||
"Canvas Toggle Lock": "Alternar bloqueo en lienzo",
|
||||
"Check for Custom Node Updates": "Buscar actualizaciones de nodos personalizados",
|
||||
"Check for Updates": "Buscar actualizaciones",
|
||||
"Clear Pending Tasks": "Borrar tareas pendientes",
|
||||
"Clear Workflow": "Borrar flujo de trabajo",
|
||||
"Clipspace": "Espacio de clip",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "Administrador de Nodos Personalizados",
|
||||
"Decrease Brush Size in MaskEditor": "Disminuir tamaño del pincel en MaskEditor",
|
||||
"Delete Selected Items": "Eliminar elementos seleccionados",
|
||||
"Desktop User Guide": "Guía de usuario de escritorio",
|
||||
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
|
||||
"Edit": "Editar",
|
||||
"Edit Subgraph Widgets": "Editar widgets de subgrafo",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "Enlaces de nodos",
|
||||
"Open": "Abrir",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Abrir Visor 3D (Beta) para Nodo Seleccionado",
|
||||
"Open Custom Nodes Folder": "Abrir carpeta de nodos personalizados",
|
||||
"Open DevTools": "Abrir DevTools",
|
||||
"Open Inputs Folder": "Abrir carpeta de entradas",
|
||||
"Open Logs Folder": "Abrir carpeta de registros",
|
||||
"Open Mask Editor for Selected Node": "Abrir el editor de mask para el nodo seleccionado",
|
||||
"Open Models Folder": "Abrir carpeta de modelos",
|
||||
"Open Outputs Folder": "Abrir carpeta de salidas",
|
||||
"Open Sign In Dialog": "Abrir diálogo de inicio de sesión",
|
||||
"Open extra_model_paths_yaml": "Abrir extra_model_paths.yaml",
|
||||
"Pin/Unpin Selected Items": "Anclar/Desanclar elementos seleccionados",
|
||||
"Pin/Unpin Selected Nodes": "Anclar/Desanclar nodos seleccionados",
|
||||
"Previous Opened Workflow": "Flujo de trabajo abierto anterior",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "Indicador de cola",
|
||||
"Queue Prompt (Front)": "Indicador de cola (Frente)",
|
||||
"Queue Selected Output Nodes": "Encolar nodos de salida seleccionados",
|
||||
"Quit": "Salir",
|
||||
"Redo": "Rehacer",
|
||||
"Refresh Node Definitions": "Actualizar definiciones de nodo",
|
||||
"Reinstall": "Reinstalar",
|
||||
"Reset View": "Restablecer vista",
|
||||
"Resize Selected Nodes": "Redimensionar Nodos Seleccionados",
|
||||
"Restart": "Reiniciar",
|
||||
"Save": "Guardar",
|
||||
"Save As": "Guardar como",
|
||||
"Show Keybindings Dialog": "Mostrar diálogo de combinaciones de teclas",
|
||||
|
||||