mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
v1.2.0 Side Bar & Menu rework (#189)
* Basic side tool bar skeleton + Theme toggle (#164) * Side bar skeleton * Fix grid layout * nit * Add theme toggle logic * Change primevue color theme to blue to match beta menu UI * Add litegraph canvas splitter overlay (#177) * Add vue wrapper * Splitter overlay * Move teleport to side bar comp * Toolbar placeholder * Move settings button from top menu to side bar (#178) * Reverse relationship between splitter overlay and sidebar component (#180) * Reverse relationship between splitter overlay and sidebar component * nit * Remove border on splitter * Fix canvas shift (#186) * Move queue/history display to side bar (#185) * Side bar placeholder * Pinia store for queue items * Flatten task item * Fix schema * computed * Switch running / pending order * Use class-transformer * nit * Show display status * Add tag severity style * Add execution time * nit * Rename to execution success * Add time display * Sort queue desc order * nit * Add remove item feature * Load workflow * Add confirmation popup * Add empty table placeholder * Remove beta menu UI's queue button/list * Add tests on litegraph widget text truncate (#191) * Add tests on litegraph widget text truncate * Updated screenshots * Revert port change * Remove screenshots * Update test expectations [skip ci] * Add back menu.settingsGroup for compatibility (#192) * Close side bar on menu location set as disabled (#194) * Remove placeholder side bar tabs (#196) --------- Co-authored-by: bymyself <abolkonsky.rem@gmail.com> Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -8,6 +8,11 @@ interface Position {
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
class ComfyNodeSearchBox {
|
||||
public readonly input: Locator;
|
||||
public readonly dropdown: Locator;
|
||||
@@ -214,6 +219,65 @@ export class ComfyPage {
|
||||
await this.page.keyboard.up("Control");
|
||||
await this.nextFrame();
|
||||
}
|
||||
|
||||
async closeMenu() {
|
||||
await this.page.click("button.comfy-close-menu-btn");
|
||||
await this.nextFrame();
|
||||
}
|
||||
|
||||
async resizeNode(nodePos: Position, nodeSize: Size, ratioX: number, ratioY: number, revertAfter: boolean = false) {
|
||||
const bottomRight = {
|
||||
x: nodePos.x + nodeSize.width,
|
||||
y: nodePos.y + nodeSize.height,
|
||||
}
|
||||
const target = {
|
||||
x: nodePos.x + nodeSize.width * ratioX,
|
||||
y: nodePos.y + nodeSize.height * ratioY,
|
||||
}
|
||||
await this.dragAndDrop(bottomRight, target);
|
||||
await this.nextFrame();
|
||||
if (revertAfter) {
|
||||
await this.dragAndDrop(target, bottomRight);
|
||||
await this.nextFrame();
|
||||
}
|
||||
}
|
||||
|
||||
async resizeKsamplerNode(percentX: number, percentY: number, revertAfter: boolean = false) {
|
||||
const ksamplerPos = {
|
||||
x: 864,
|
||||
y: 157
|
||||
}
|
||||
const ksamplerSize = {
|
||||
width: 315,
|
||||
height: 292,
|
||||
}
|
||||
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter);
|
||||
}
|
||||
|
||||
async resizeLoadCheckpointNode(percentX: number, percentY: number, revertAfter: boolean = false) {
|
||||
const loadCheckpointPos = {
|
||||
x: 25,
|
||||
y: 440,
|
||||
}
|
||||
const loadCheckpointSize = {
|
||||
width: 320,
|
||||
height: 120,
|
||||
}
|
||||
this.resizeNode(loadCheckpointPos, loadCheckpointSize, percentX, percentY, revertAfter);
|
||||
}
|
||||
|
||||
async resizeEmptyLatentNode(percentX: number, percentY: number, revertAfter: boolean = false) {
|
||||
const emptyLatentPos = {
|
||||
x: 475,
|
||||
y: 580,
|
||||
}
|
||||
const emptyLatentSize = {
|
||||
width: 303,
|
||||
height: 132,
|
||||
}
|
||||
this.resizeNode(emptyLatentPos, emptyLatentSize, percentX, percentY, revertAfter);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
|
||||
|
||||
28
browser_tests/textWidgetTruncate.spec.ts
Normal file
28
browser_tests/textWidgetTruncate.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { comfyPageFixture as test } from "./ComfyPage";
|
||||
|
||||
test.describe("Combo text widget", () => {
|
||||
test("Truncates text when resized", async ({ comfyPage }) => {
|
||||
await comfyPage.resizeLoadCheckpointNode(0.2, 1);
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
"load-checkpoint-resized-min-width.png"
|
||||
);
|
||||
await comfyPage.closeMenu();
|
||||
await comfyPage.resizeKsamplerNode(0.2, 1);
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`ksampler-resized-min-width.png`
|
||||
);
|
||||
});
|
||||
|
||||
test("Doesn't truncate when space still available", async ({ comfyPage }) => {
|
||||
await comfyPage.resizeEmptyLatentNode(0.8, 0.8);
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
"empty-latent-resized-80-percent.png"
|
||||
);
|
||||
});
|
||||
|
||||
test("Can revert to full text", async ({ comfyPage }) => {
|
||||
await comfyPage.resizeLoadCheckpointNode(0.8, 1, true);
|
||||
await expect(comfyPage.canvas).toHaveScreenshot("resized-to-original.png");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -12,6 +12,7 @@
|
||||
font-family: 'Roboto Mono', 'Noto Color Emoji';
|
||||
}
|
||||
</style> -->
|
||||
<script src="node_modules/reflect-metadata/Reflect.js"></script>
|
||||
<script type="module">
|
||||
window["__COMFYUI_FRONTEND_VERSION__"] = __COMFYUI_FRONTEND_VERSION__;
|
||||
console.log("ComfyUI Front-end version:", __COMFYUI_FRONTEND_VERSION__);
|
||||
|
||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -11,11 +11,14 @@
|
||||
"@comfyorg/litegraph": "^0.7.26",
|
||||
"@primevue/themes": "^4.0.0-rc.2",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fuse.js": "^7.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"pinia": "^2.1.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.0.0-rc.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"vue": "^3.4.31",
|
||||
"zod": "^3.23.8",
|
||||
"zod-validation-error": "^3.3.0"
|
||||
@@ -3535,6 +3538,11 @@
|
||||
"@vue/shared": "3.4.31"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.3.tgz",
|
||||
"integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw=="
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.31.tgz",
|
||||
@@ -4199,6 +4207,11 @@
|
||||
"integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/class-transformer": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
|
||||
@@ -8133,6 +8146,56 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz",
|
||||
"integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.5.0",
|
||||
"vue-demi": ">=0.14.5"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.4.0",
|
||||
"typescript": ">=4.4.4",
|
||||
"vue": "^2.6.14 || ^3.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/vue-demi": {
|
||||
"version": "0.14.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
|
||||
"integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
|
||||
@@ -8510,6 +8573,11 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
},
|
||||
"node_modules/regenerate": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
||||
|
||||
@@ -49,11 +49,14 @@
|
||||
"@comfyorg/litegraph": "^0.7.26",
|
||||
"@primevue/themes": "^4.0.0-rc.2",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fuse.js": "^7.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"pinia": "^2.1.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.0.0-rc.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"vue": "^3.4.31",
|
||||
"zod": "^3.23.8",
|
||||
"zod-validation-error": "^3.3.0"
|
||||
|
||||
@@ -2,12 +2,21 @@
|
||||
<ProgressSpinner v-if="isLoading" class="spinner"></ProgressSpinner>
|
||||
<div v-else>
|
||||
<NodeSearchboxPopover v-if="nodeSearchEnabled" />
|
||||
<teleport to="#graph-canvas-container">
|
||||
<LiteGraphCanvasSplitterOverlay>
|
||||
<template #side-bar-panel="{ setPanelVisible }">
|
||||
<SideToolBar @change="setPanelVisible($event)" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, provide, ref } from "vue";
|
||||
import NodeSearchboxPopover from "@/components/NodeSearchBoxPopover.vue";
|
||||
import SideToolBar from "@/components/sidebar/SideToolBar.vue";
|
||||
import LiteGraphCanvasSplitterOverlay from "@/components/LiteGraphCanvasSplitterOverlay.vue";
|
||||
import ProgressSpinner from "primevue/progressspinner";
|
||||
import {
|
||||
NodeSearchService,
|
||||
|
||||
@@ -47,9 +47,34 @@ body {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
/**
|
||||
+------------------+------------------+------------------+
|
||||
| |
|
||||
| .comfyui-body- |
|
||||
| top |
|
||||
| (spans all cols) |
|
||||
| |
|
||||
+------------------+------------------+------------------+
|
||||
| | | |
|
||||
| .comfyui-body- | #graph-canvas | .comfyui-body- |
|
||||
| left | | right |
|
||||
| | | |
|
||||
| | | |
|
||||
+------------------+------------------+------------------+
|
||||
| |
|
||||
| .comfyui-body- |
|
||||
| bottom |
|
||||
| (spans all cols) |
|
||||
| |
|
||||
+------------------+------------------+------------------+
|
||||
*/
|
||||
|
||||
.comfyui-body-top {
|
||||
order: -5;
|
||||
/* Span across all columns */
|
||||
grid-column: 1/-1;
|
||||
/* Position at the first row */
|
||||
grid-row: 1;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -57,25 +82,41 @@ body {
|
||||
|
||||
.comfyui-body-left {
|
||||
order: -4;
|
||||
/* Position in the first column */
|
||||
grid-column: 1;
|
||||
/* Position below the top element */
|
||||
grid-row: 2;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#graph-canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
order: -3;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#graph-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
order: -3;
|
||||
}
|
||||
|
||||
.comfyui-body-right {
|
||||
order: -2;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.comfyui-body-bottom {
|
||||
order: -1;
|
||||
order: 4;
|
||||
/* Span across all columns */
|
||||
grid-column: 1/-1;
|
||||
grid-row: 3;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
58
src/components/LiteGraphCanvasSplitterOverlay.vue
Normal file
58
src/components/LiteGraphCanvasSplitterOverlay.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Splitter class="splitter-overlay" :pt:gutter="gutterClass">
|
||||
<SplitterPanel
|
||||
class="side-bar-panel"
|
||||
:minSize="10"
|
||||
:size="20"
|
||||
v-show="sideBarPanelVisible"
|
||||
>
|
||||
<slot name="side-bar-panel" :setPanelVisible="setPanelVisible"></slot>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel class="graph-canvas-panel" :size="100">
|
||||
<div></div>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Splitter from "primevue/splitter";
|
||||
import SplitterPanel from "primevue/splitterpanel";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const sideBarPanelVisible = ref(false);
|
||||
const setPanelVisible = (visible: boolean) => {
|
||||
sideBarPanelVisible.value = visible;
|
||||
};
|
||||
const gutterClass = computed(() => {
|
||||
return sideBarPanelVisible.value ? "" : "gutter-hidden";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.p-splitter-gutter {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gutter-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.side-bar-panel {
|
||||
background-color: var(--bg-color);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.splitter-overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: transparent;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
50
src/components/sidebar/SideBarIcon.vue
Normal file
50
src/components/sidebar/SideBarIcon.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<Button
|
||||
:icon="props.icon"
|
||||
text
|
||||
:pt="{
|
||||
root: `side-bar-button ${
|
||||
props.selected
|
||||
? 'p-button-primary side-bar-button-selected'
|
||||
: 'p-button-secondary'
|
||||
}`,
|
||||
icon: 'side-bar-button-icon',
|
||||
}"
|
||||
@click="emit('click', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from "primevue/button";
|
||||
|
||||
const props = defineProps({
|
||||
icon: String,
|
||||
selected: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.p-button-icon.side-bar-button-icon {
|
||||
font-size: var(--sidebar-icon-size) !important;
|
||||
}
|
||||
|
||||
.side-bar-button-selected .p-button-icon.side-bar-button-icon {
|
||||
font-size: var(--sidebar-icon-size) !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.side-bar-button {
|
||||
width: var(--sidebar-width);
|
||||
height: var(--sidebar-width);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.side-bar-button.side-bar-button-selected,
|
||||
.side-bar-button.side-bar-button-selected:hover {
|
||||
border-left: 4px solid var(--p-button-text-primary-color);
|
||||
}
|
||||
</style>
|
||||
12
src/components/sidebar/SideBarSettingsToggleIcon.vue
Normal file
12
src/components/sidebar/SideBarSettingsToggleIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<SideBarIcon icon="pi pi-cog" @click="showSetting" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { app } from "@/scripts/app";
|
||||
import SideBarIcon from "./SideBarIcon.vue";
|
||||
|
||||
const showSetting = () => {
|
||||
app.ui.settings.show();
|
||||
};
|
||||
</script>
|
||||
20
src/components/sidebar/SideBarThemeToggleIcon.vue
Normal file
20
src/components/sidebar/SideBarThemeToggleIcon.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<SideBarIcon :icon="icon" @click="toggleTheme" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import SideBarIcon from "./SideBarIcon.vue";
|
||||
import { app } from "@/scripts/app";
|
||||
|
||||
const isDarkMode = ref(false);
|
||||
const icon = computed(() => (isDarkMode.value ? "pi pi-sun" : "pi pi-moon"));
|
||||
const themeId = computed(() => (isDarkMode.value ? "dark" : "light"));
|
||||
const toggleTheme = () => {
|
||||
isDarkMode.value = !isDarkMode.value;
|
||||
};
|
||||
|
||||
watch(themeId, (newThemeId) => {
|
||||
app.ui.settings.setSettingValue("Comfy.ColorPalette", newThemeId);
|
||||
});
|
||||
</script>
|
||||
90
src/components/sidebar/SideToolBar.vue
Normal file
90
src/components/sidebar/SideToolBar.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<teleport to=".comfyui-body-left">
|
||||
<nav class="side-tool-bar-container">
|
||||
<SideBarIcon
|
||||
v-for="item in items"
|
||||
:icon="item.icon"
|
||||
:selected="item === selectedItem"
|
||||
@click="onItemClick(item)"
|
||||
/>
|
||||
<div class="side-tool-bar-end">
|
||||
<SideBarThemeToggleIcon />
|
||||
<SideBarSettingsToggleIcon />
|
||||
</div>
|
||||
</nav>
|
||||
</teleport>
|
||||
<component :is="selectedItem?.component" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SideBarIcon from "./SideBarIcon.vue";
|
||||
import SideBarThemeToggleIcon from "./SideBarThemeToggleIcon.vue";
|
||||
import SideBarSettingsToggleIcon from "./SideBarSettingsToggleIcon.vue";
|
||||
import NodeDetailSideBarItem from "./items/NodeDetailSideBarItem.vue";
|
||||
import QueueSideBarItem from "./items/QueueSideBarItem.vue";
|
||||
import { markRaw, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
const items = ref([
|
||||
// { icon: "pi pi-map", component: markRaw(NodeDetailSideBarItem) },
|
||||
{ icon: "pi pi-history", component: markRaw(QueueSideBarItem) },
|
||||
]);
|
||||
const selectedItem = ref(null);
|
||||
const onItemClick = (item) => {
|
||||
if (selectedItem.value === item) {
|
||||
selectedItem.value = null;
|
||||
return;
|
||||
}
|
||||
selectedItem.value = item;
|
||||
};
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
watch(selectedItem, (newVal) => {
|
||||
emit("change", newVal !== null);
|
||||
});
|
||||
|
||||
const onBetaMenuDisabled = () => {
|
||||
selectedItem.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener(
|
||||
"comfy:setting:beta-menu-disabled",
|
||||
onBetaMenuDisabled
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener(
|
||||
"comfy:setting:beta-menu-disabled",
|
||||
onBetaMenuDisabled
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--sidebar-width: 64px;
|
||||
--sidebar-icon-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.side-tool-bar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
pointer-events: auto;
|
||||
|
||||
width: var(--sidebar-width);
|
||||
height: 100%;
|
||||
|
||||
background-color: var(--comfy-menu-bg);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.side-tool-bar-end {
|
||||
align-self: flex-end;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
20
src/components/sidebar/items/NodeDetailSideBarItem.vue
Normal file
20
src/components/sidebar/items/NodeDetailSideBarItem.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<Accordion>
|
||||
<AccordionPanel v-for="node in nodes" :key="node.id">
|
||||
<AccordionHeader>
|
||||
<div>{{ node.id }} - {{ node.type }}</div>
|
||||
</AccordionHeader>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defaultGraph } from "@/scripts/defaultGraph";
|
||||
import Accordion from "primevue/accordion";
|
||||
import AccordionHeader from "primevue/accordionheader";
|
||||
import AccordionPanel from "primevue/accordionpanel";
|
||||
|
||||
/* Placeholder workflow display */
|
||||
const workflow = defaultGraph;
|
||||
const nodes = workflow.nodes.sort((a, b) => a.id - b.id);
|
||||
</script>
|
||||
176
src/components/sidebar/items/QueueSideBarItem.vue
Normal file
176
src/components/sidebar/items/QueueSideBarItem.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<DataTable
|
||||
v-if="tasks.length > 0"
|
||||
:value="tasks"
|
||||
dataKey="promptId"
|
||||
class="queue-table"
|
||||
>
|
||||
<Column header="STATUS">
|
||||
<template #body="{ data }">
|
||||
<Tag :severity="taskTagSeverity(data.displayStatus)">
|
||||
{{ data.displayStatus.toUpperCase() }}
|
||||
</Tag>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="TIME" :pt="{ root: { class: 'queue-time-cell' } }">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.isHistory" class="queue-time-cell-content">
|
||||
{{ formatTime(data.executionTimeInSeconds) }}
|
||||
</div>
|
||||
<div v-else-if="data.isRunning" class="queue-time-cell-content">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
</div>
|
||||
<div v-else class="queue-time-cell-content">...</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
:pt="{
|
||||
headerCell: {
|
||||
class: 'queue-tool-header-cell',
|
||||
},
|
||||
bodyCell: {
|
||||
class: 'queue-tool-body-cell',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<Toast />
|
||||
<ConfirmPopup />
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="primary"
|
||||
@click="confirmRemoveAll($event)"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-file-export"
|
||||
text
|
||||
severity="primary"
|
||||
@click="data.loadWorkflow()"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="removeTask(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
<div>
|
||||
<Message icon="pi pi-info" severity="error">
|
||||
<span class="ml-2">No tasks</span>
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DataTable from "primevue/datatable";
|
||||
import Column from "primevue/column";
|
||||
import Tag from "primevue/tag";
|
||||
import Button from "primevue/button";
|
||||
import ConfirmPopup from "primevue/confirmpopup";
|
||||
import Toast from "primevue/toast";
|
||||
import Message from "primevue/message";
|
||||
import { useConfirm } from "primevue/useconfirm";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import {
|
||||
TaskItemDisplayStatus,
|
||||
TaskItemImpl,
|
||||
useQueueStore,
|
||||
} from "@/stores/queueStore";
|
||||
import { computed, onMounted } from "vue";
|
||||
import { api } from "@/scripts/api";
|
||||
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const queueStore = useQueueStore();
|
||||
|
||||
const tasks = computed(() => queueStore.tasks);
|
||||
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
|
||||
switch (status) {
|
||||
case TaskItemDisplayStatus.Pending:
|
||||
return "secondary";
|
||||
case TaskItemDisplayStatus.Running:
|
||||
return "info";
|
||||
case TaskItemDisplayStatus.Completed:
|
||||
return "success";
|
||||
case TaskItemDisplayStatus.Failed:
|
||||
return "danger";
|
||||
case TaskItemDisplayStatus.Cancelled:
|
||||
return "warning";
|
||||
}
|
||||
};
|
||||
const formatTime = (time?: number) => {
|
||||
if (time === undefined) {
|
||||
return "";
|
||||
}
|
||||
return `${time.toFixed(2)}s`;
|
||||
};
|
||||
const removeTask = (task: TaskItemImpl) => {
|
||||
if (task.isRunning) {
|
||||
api.interrupt();
|
||||
}
|
||||
queueStore.delete(task);
|
||||
};
|
||||
const removeAllTasks = async () => {
|
||||
await queueStore.clear();
|
||||
};
|
||||
const confirmRemoveAll = (event) => {
|
||||
confirm.require({
|
||||
target: event.currentTarget,
|
||||
message: "Do you want to delete all tasks?",
|
||||
icon: "pi pi-info-circle",
|
||||
rejectProps: {
|
||||
label: "Cancel",
|
||||
severity: "secondary",
|
||||
outlined: true,
|
||||
},
|
||||
acceptProps: {
|
||||
label: "Delete",
|
||||
severity: "danger",
|
||||
},
|
||||
accept: async () => {
|
||||
await removeAllTasks();
|
||||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Confirmed",
|
||||
detail: "Tasks deleted",
|
||||
life: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
onMounted(() => {
|
||||
api.addEventListener("status", () => {
|
||||
queueStore.update();
|
||||
});
|
||||
|
||||
queueStore.update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.queue-tool-header-cell {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.queue-tool-body-cell {
|
||||
display: table-cell;
|
||||
text-align: right !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.queue-time-cell-content {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.queue-table {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
17
src/main.ts
17
src/main.ts
@@ -1,12 +1,24 @@
|
||||
import { createApp } from "vue";
|
||||
import PrimeVue from "primevue/config";
|
||||
import Aura from "@primevue/themes/aura";
|
||||
import { definePreset } from "@primevue/themes";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
import ToastService from "primevue/toastservice";
|
||||
import "primeicons/primeicons.css";
|
||||
|
||||
import App from "./App.vue";
|
||||
import { app as comfyApp } from "@/scripts/app";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
semantic: {
|
||||
// @ts-ignore
|
||||
primary: Aura.primitive.blue,
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
comfyApp.setup().then(() => {
|
||||
window["app"] = comfyApp;
|
||||
@@ -15,7 +27,7 @@ comfyApp.setup().then(() => {
|
||||
app
|
||||
.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
preset: ComfyUIPreset,
|
||||
options: {
|
||||
prefix: "p",
|
||||
cssLayer: false,
|
||||
@@ -25,5 +37,8 @@ comfyApp.setup().then(() => {
|
||||
},
|
||||
},
|
||||
})
|
||||
.use(ConfirmationService)
|
||||
.use(ToastService)
|
||||
.use(pinia)
|
||||
.mount("#vue-app");
|
||||
});
|
||||
|
||||
@@ -184,6 +184,11 @@ class ComfyApi extends EventTarget {
|
||||
new CustomEvent("execution_start", { detail: msg.data })
|
||||
);
|
||||
break;
|
||||
case "execution_success":
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("execution_success", { detail: msg.data })
|
||||
);
|
||||
break;
|
||||
case "execution_error":
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("execution_error", { detail: msg.data })
|
||||
@@ -315,10 +320,14 @@ class ComfyApi extends EventTarget {
|
||||
return {
|
||||
// Running action uses a different endpoint for cancelling
|
||||
Running: data.queue_running.map((prompt) => ({
|
||||
taskType: "Running",
|
||||
prompt,
|
||||
remove: { name: "Cancel", cb: () => api.interrupt() },
|
||||
})),
|
||||
Pending: data.queue_pending.map((prompt) => ({ prompt })),
|
||||
Pending: data.queue_pending.map((prompt) => ({
|
||||
taskType: "Pending",
|
||||
prompt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -335,7 +344,11 @@ class ComfyApi extends EventTarget {
|
||||
): Promise<{ History: HistoryTaskItem[] }> {
|
||||
try {
|
||||
const res = await this.fetchApi(`/history?max_items=${max_items}`);
|
||||
return { History: Object.values(await res.json()) };
|
||||
return {
|
||||
History: Object.values(await res.json()).map(
|
||||
(item: HistoryTaskItem) => ({ ...item, taskType: "History" })
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { History: [] };
|
||||
|
||||
@@ -1845,13 +1845,18 @@ export class ComfyApp {
|
||||
await this.#setUser();
|
||||
|
||||
// Create and mount the LiteGraph in the DOM
|
||||
const canvasContainer = document.createElement("div");
|
||||
canvasContainer.id = "graph-canvas-container";
|
||||
|
||||
const mainCanvas = document.createElement("canvas");
|
||||
mainCanvas.style.touchAction = "none";
|
||||
const canvasEl = (this.canvasEl = Object.assign(mainCanvas, {
|
||||
id: "graph-canvas",
|
||||
}));
|
||||
canvasEl.tabIndex = 1;
|
||||
document.body.prepend(canvasEl);
|
||||
canvasContainer.prepend(canvasEl);
|
||||
document.body.prepend(canvasContainer);
|
||||
|
||||
this.resizeCanvas();
|
||||
|
||||
await Promise.all([
|
||||
|
||||
@@ -5,10 +5,8 @@ import { downloadBlob } from "../../utils";
|
||||
import { ComfyButton } from "../components/button";
|
||||
import { ComfyButtonGroup } from "../components/buttonGroup";
|
||||
import { ComfySplitButton } from "../components/splitButton";
|
||||
import { ComfyViewHistoryButton } from "./viewHistory";
|
||||
import { ComfyQueueButton } from "./queueButton";
|
||||
import { ComfyWorkflowsMenu } from "./workflows";
|
||||
import { ComfyViewQueueButton } from "./viewQueue";
|
||||
import { getInteruptButton } from "./interruptButton";
|
||||
import "./menu.css";
|
||||
import type { ComfySettingsDialog } from "../settings";
|
||||
@@ -128,19 +126,10 @@ export class ComfyAppMenu {
|
||||
},
|
||||
})
|
||||
);
|
||||
this.settingsGroup = new ComfyButtonGroup(
|
||||
new ComfyButton({
|
||||
icon: "cog",
|
||||
content: "Settings",
|
||||
tooltip: "Open settings",
|
||||
action: () => {
|
||||
app.ui.settings.show();
|
||||
},
|
||||
})
|
||||
);
|
||||
// Keep the settings group as there are custom scripts attaching extra
|
||||
// elements to it.
|
||||
this.settingsGroup = new ComfyButtonGroup();
|
||||
this.viewGroup = new ComfyButtonGroup(
|
||||
new ComfyViewHistoryButton(app).element,
|
||||
new ComfyViewQueueButton(app).element,
|
||||
getInteruptButton("nlg-hide").element
|
||||
);
|
||||
this.mobileMenuButton = new ComfyButton({
|
||||
@@ -193,6 +182,7 @@ export class ComfyAppMenu {
|
||||
app.ui.menuContainer.style.removeProperty("display");
|
||||
this.element.style.display = "none";
|
||||
app.ui.restoreMenuPosition();
|
||||
document.dispatchEvent(new Event("comfy:setting:beta-menu-disabled"));
|
||||
}
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
},
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ComfyApp } from "@/scripts/app";
|
||||
import { ComfyButton } from "../components/button";
|
||||
import { ComfyViewList, ComfyViewListButton } from "./viewList";
|
||||
|
||||
export class ComfyViewHistoryButton extends ComfyViewListButton {
|
||||
constructor(app: ComfyApp) {
|
||||
super(app, {
|
||||
button: new ComfyButton({
|
||||
content: "View History",
|
||||
icon: "history",
|
||||
tooltip: "View history",
|
||||
classList: "comfyui-button comfyui-history-button",
|
||||
}),
|
||||
list: ComfyViewHistoryList,
|
||||
mode: "History",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyViewHistoryList extends ComfyViewList {
|
||||
async loadItems() {
|
||||
const items = await super.loadItems();
|
||||
items["History"].reverse();
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { ComfyButton } from "../components/button";
|
||||
import { $el } from "../../ui";
|
||||
import { api } from "../../api";
|
||||
import { ComfyPopup } from "../components/popup";
|
||||
import type { ComfyApp } from "@/scripts/app";
|
||||
|
||||
type ViewListMode = "Queue" | "History";
|
||||
|
||||
export class ComfyViewListButton {
|
||||
popup: ComfyPopup;
|
||||
app: ComfyApp;
|
||||
button: ComfyButton;
|
||||
element: HTMLDivElement;
|
||||
list: ComfyViewList;
|
||||
|
||||
get open() {
|
||||
return this.popup.open;
|
||||
}
|
||||
|
||||
set open(open) {
|
||||
this.popup.open = open;
|
||||
}
|
||||
|
||||
constructor(
|
||||
app: ComfyApp,
|
||||
{
|
||||
button,
|
||||
list,
|
||||
mode,
|
||||
}: { button: ComfyButton; list: typeof ComfyViewList; mode: ViewListMode }
|
||||
) {
|
||||
this.app = app;
|
||||
this.button = button;
|
||||
this.element = $el(
|
||||
"div.comfyui-button-wrapper",
|
||||
this.button.element
|
||||
) as HTMLDivElement;
|
||||
this.popup = new ComfyPopup({
|
||||
target: this.element,
|
||||
container: this.element,
|
||||
horizontal: "right",
|
||||
});
|
||||
this.list = new (list ?? ComfyViewList)(app, mode, this.popup);
|
||||
this.popup.children = [this.list.element];
|
||||
this.popup.addEventListener("open", () => {
|
||||
this.list.update();
|
||||
});
|
||||
this.popup.addEventListener("close", () => {
|
||||
this.list.close();
|
||||
});
|
||||
this.button.withPopup(this.popup);
|
||||
|
||||
api.addEventListener("status", () => {
|
||||
if (this.popup.open) {
|
||||
this.popup.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyViewList {
|
||||
app: ComfyApp;
|
||||
mode: ViewListMode;
|
||||
popup: ComfyPopup;
|
||||
type: string;
|
||||
items: HTMLElement;
|
||||
clear: ComfyButton;
|
||||
refresh: ComfyButton;
|
||||
element: HTMLElement;
|
||||
constructor(app: ComfyApp, mode: ViewListMode, popup: ComfyPopup) {
|
||||
this.app = app;
|
||||
this.mode = mode;
|
||||
this.popup = popup;
|
||||
this.type = mode.toLowerCase();
|
||||
|
||||
this.items = $el(`div.comfyui-${this.type}-items.comfyui-view-list-items`);
|
||||
this.clear = new ComfyButton({
|
||||
icon: "cancel",
|
||||
content: "Clear",
|
||||
action: async () => {
|
||||
this.showSpinner(false);
|
||||
await api.clearItems(this.type);
|
||||
await this.update();
|
||||
},
|
||||
});
|
||||
|
||||
this.refresh = new ComfyButton({
|
||||
icon: "refresh",
|
||||
content: "Refresh",
|
||||
action: async () => {
|
||||
await this.update(false);
|
||||
},
|
||||
});
|
||||
|
||||
this.element = $el(
|
||||
`div.comfyui-${this.type}-popup.comfyui-view-list-popup`,
|
||||
[
|
||||
$el("h3", mode),
|
||||
$el("header", [this.clear.element, this.refresh.element]),
|
||||
this.items,
|
||||
]
|
||||
);
|
||||
|
||||
api.addEventListener("status", () => {
|
||||
if (this.popup.open) {
|
||||
this.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.items.replaceChildren();
|
||||
}
|
||||
|
||||
async update(resize = true) {
|
||||
this.showSpinner(resize);
|
||||
const res = await this.loadItems();
|
||||
let any = false;
|
||||
|
||||
const names = Object.keys(res);
|
||||
const sections = names
|
||||
.map((section) => {
|
||||
const items = res[section];
|
||||
if (items?.length) {
|
||||
any = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
if (names.length > 1) {
|
||||
rows.push($el("h5", section));
|
||||
}
|
||||
rows.push(...items.flatMap((item) => this.createRow(item, section)));
|
||||
return $el("section", rows);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (any) {
|
||||
this.items.replaceChildren(...sections);
|
||||
} else {
|
||||
this.items.replaceChildren($el("h5", "None"));
|
||||
}
|
||||
|
||||
this.popup.update();
|
||||
this.clear.enabled = this.refresh.enabled = true;
|
||||
this.element.style.removeProperty("height");
|
||||
}
|
||||
|
||||
showSpinner(resize = true) {
|
||||
// if (!this.spinner) {
|
||||
// this.spinner = createSpinner();
|
||||
// }
|
||||
// if (!resize) {
|
||||
// this.element.style.height = this.element.clientHeight + "px";
|
||||
// }
|
||||
// this.clear.enabled = this.refresh.enabled = false;
|
||||
// this.items.replaceChildren(
|
||||
// $el(
|
||||
// "div",
|
||||
// {
|
||||
// style: {
|
||||
// fontSize: "18px",
|
||||
// },
|
||||
// },
|
||||
// this.spinner
|
||||
// )
|
||||
// );
|
||||
// this.popup.update();
|
||||
}
|
||||
|
||||
async loadItems() {
|
||||
return await api.getItems(this.type);
|
||||
}
|
||||
|
||||
getRow(item, section) {
|
||||
return {
|
||||
text: item.prompt[0] + "",
|
||||
actions: [
|
||||
{
|
||||
text: "Load",
|
||||
action: async () => {
|
||||
try {
|
||||
await this.app.loadGraphData(
|
||||
item.prompt[3].extra_pnginfo.workflow
|
||||
);
|
||||
if (item.outputs) {
|
||||
this.app.nodeOutputs = item.outputs;
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error loading workflow: " + error.message);
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
action: async () => {
|
||||
try {
|
||||
await api.deleteItem(this.type, item.prompt[1]);
|
||||
this.update();
|
||||
} catch (error) {}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
createRow = (item, section) => {
|
||||
const row = this.getRow(item, section);
|
||||
return [
|
||||
$el("span", row.text),
|
||||
...row.actions.map(
|
||||
(a) =>
|
||||
new ComfyButton({
|
||||
content: a.text,
|
||||
action: async (e, btn) => {
|
||||
btn.enabled = false;
|
||||
try {
|
||||
await a.action();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
btn.enabled = true;
|
||||
}
|
||||
},
|
||||
}).element
|
||||
),
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ComfyButton } from "../components/button";
|
||||
import { ComfyViewList, ComfyViewListButton } from "./viewList";
|
||||
import { api } from "../../api";
|
||||
import type { ComfyApp } from "@/scripts/app";
|
||||
|
||||
export class ComfyViewQueueButton extends ComfyViewListButton {
|
||||
constructor(app: ComfyApp) {
|
||||
super(app, {
|
||||
button: new ComfyButton({
|
||||
content: "View Queue",
|
||||
icon: "format-list-numbered",
|
||||
tooltip: "View queue",
|
||||
classList: "comfyui-button comfyui-queue-button",
|
||||
}),
|
||||
list: ComfyViewQueueList,
|
||||
mode: "Queue",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyViewQueueList extends ComfyViewList {
|
||||
getRow = (item, section) => {
|
||||
if (section !== "Running") {
|
||||
return super.getRow(item, section);
|
||||
}
|
||||
return {
|
||||
text: item.prompt[0] + "",
|
||||
actions: [
|
||||
{
|
||||
text: "Load",
|
||||
action: async () => {
|
||||
try {
|
||||
await this.app.loadGraphData(
|
||||
item.prompt[3].extra_pnginfo.workflow
|
||||
);
|
||||
if (item.outputs) {
|
||||
this.app.nodeOutputs = item.outputs;
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error loading workflow: " + error.message);
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Cancel",
|
||||
action: async () => {
|
||||
try {
|
||||
await api.interrupt();
|
||||
} catch (error) {}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
206
src/stores/queueStore.ts
Normal file
206
src/stores/queueStore.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { api } from "@/scripts/api";
|
||||
import { app } from "@/scripts/app";
|
||||
import {
|
||||
validateTaskItem,
|
||||
TaskItem,
|
||||
TaskType,
|
||||
TaskPrompt,
|
||||
TaskStatus,
|
||||
TaskOutput,
|
||||
} from "@/types/apiTypes";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import _ from "lodash";
|
||||
import { defineStore } from "pinia";
|
||||
import { toRaw } from "vue";
|
||||
|
||||
// Task type used in the API.
|
||||
export type APITaskType = "queue" | "history";
|
||||
|
||||
export enum TaskItemDisplayStatus {
|
||||
Running = "Running",
|
||||
Pending = "Pending",
|
||||
Completed = "Completed",
|
||||
Failed = "Failed",
|
||||
Cancelled = "Cancelled",
|
||||
}
|
||||
|
||||
export class TaskItemImpl {
|
||||
taskType: TaskType;
|
||||
prompt: TaskPrompt;
|
||||
status?: TaskStatus;
|
||||
outputs?: TaskOutput;
|
||||
|
||||
get apiTaskType(): APITaskType {
|
||||
switch (this.taskType) {
|
||||
case "Running":
|
||||
case "Pending":
|
||||
return "queue";
|
||||
case "History":
|
||||
return "history";
|
||||
}
|
||||
}
|
||||
|
||||
get queueIndex() {
|
||||
return this.prompt[0];
|
||||
}
|
||||
|
||||
get promptId() {
|
||||
return this.prompt[1];
|
||||
}
|
||||
|
||||
get promptInputs() {
|
||||
return this.prompt[2];
|
||||
}
|
||||
|
||||
get extraData() {
|
||||
return this.prompt[3];
|
||||
}
|
||||
|
||||
get outputsToExecute() {
|
||||
return this.prompt[4];
|
||||
}
|
||||
|
||||
get extraPngInfo() {
|
||||
return this.extraData.extra_pnginfo;
|
||||
}
|
||||
|
||||
get clientId() {
|
||||
return this.extraData.client_id;
|
||||
}
|
||||
|
||||
get workflow() {
|
||||
return this.extraPngInfo.workflow;
|
||||
}
|
||||
|
||||
get messages() {
|
||||
return this.status?.messages || [];
|
||||
}
|
||||
|
||||
get interrupted() {
|
||||
return _.some(
|
||||
this.messages,
|
||||
(message) => message[0] === "execution_interrupted"
|
||||
);
|
||||
}
|
||||
|
||||
get isHistory() {
|
||||
return this.taskType === "History";
|
||||
}
|
||||
|
||||
get isRunning() {
|
||||
return this.taskType === "Running";
|
||||
}
|
||||
|
||||
get displayStatus(): TaskItemDisplayStatus {
|
||||
switch (this.taskType) {
|
||||
case "Running":
|
||||
return TaskItemDisplayStatus.Running;
|
||||
case "Pending":
|
||||
return TaskItemDisplayStatus.Pending;
|
||||
case "History":
|
||||
switch (this.status!.status_str) {
|
||||
case "success":
|
||||
return TaskItemDisplayStatus.Completed;
|
||||
case "error":
|
||||
return this.interrupted
|
||||
? TaskItemDisplayStatus.Cancelled
|
||||
: TaskItemDisplayStatus.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get executionStartTimestamp() {
|
||||
const message = this.messages.find(
|
||||
(message) => message[0] === "execution_start"
|
||||
);
|
||||
return message ? message[1].timestamp : undefined;
|
||||
}
|
||||
|
||||
get executionEndTimestamp() {
|
||||
const messages = this.messages.filter((message) =>
|
||||
[
|
||||
"execution_success",
|
||||
"execution_interrupted",
|
||||
"execution_error",
|
||||
].includes(message[0])
|
||||
);
|
||||
if (!messages.length) {
|
||||
return undefined;
|
||||
}
|
||||
return _.max(messages.map((message) => message[1].timestamp));
|
||||
}
|
||||
|
||||
get executionTime() {
|
||||
if (!this.executionStartTimestamp || !this.executionEndTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
return this.executionEndTimestamp - this.executionStartTimestamp;
|
||||
}
|
||||
|
||||
get executionTimeInSeconds() {
|
||||
return this.executionTime !== undefined
|
||||
? this.executionTime / 1000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
public async loadWorkflow() {
|
||||
await app.loadGraphData(toRaw(this.workflow));
|
||||
if (this.outputs) {
|
||||
app.nodeOutputs = toRaw(this.outputs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface State {
|
||||
runningTasks: TaskItemImpl[];
|
||||
pendingTasks: TaskItemImpl[];
|
||||
historyTasks: TaskItemImpl[];
|
||||
}
|
||||
|
||||
export const useQueueStore = defineStore("queue", {
|
||||
state: (): State => ({
|
||||
runningTasks: [],
|
||||
pendingTasks: [],
|
||||
historyTasks: [],
|
||||
}),
|
||||
getters: {
|
||||
tasks(state) {
|
||||
return [
|
||||
...state.pendingTasks,
|
||||
...state.runningTasks,
|
||||
...state.historyTasks,
|
||||
];
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
// Fetch the queue data from the API
|
||||
async update() {
|
||||
const [queue, history] = await Promise.all([
|
||||
api.getQueue(),
|
||||
api.getHistory(),
|
||||
]);
|
||||
|
||||
const toClassAll = (tasks: TaskItem[]): TaskItemImpl[] =>
|
||||
tasks
|
||||
.map((task) => validateTaskItem(task))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => plainToClass(TaskItemImpl, result.data))
|
||||
// Desc order to show the latest tasks first
|
||||
.sort((a, b) => b.queueIndex - a.queueIndex);
|
||||
|
||||
this.runningTasks = toClassAll(queue.Running);
|
||||
this.pendingTasks = toClassAll(queue.Pending);
|
||||
this.historyTasks = toClassAll(history.History);
|
||||
},
|
||||
async clear() {
|
||||
await Promise.all(
|
||||
["queue", "history"].map((type) => api.clearItems(type))
|
||||
);
|
||||
await this.update();
|
||||
},
|
||||
async delete(task: TaskItemImpl) {
|
||||
await api.deleteItem(task.apiTaskType, task.promptId);
|
||||
await this.update();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,17 +2,17 @@ import { ZodType, z } from "zod";
|
||||
import { zComfyWorkflow } from "./comfyWorkflow";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const zNodeId = z.number();
|
||||
const zNodeId = z.union([z.number(), z.string()]);
|
||||
const zNodeType = z.string();
|
||||
const zQueueIndex = z.number();
|
||||
const zPromptId = z.string();
|
||||
|
||||
const zPromptItem = z.object({
|
||||
const zPromptInputItem = z.object({
|
||||
inputs: z.record(z.string(), z.any()),
|
||||
class_type: zNodeType,
|
||||
});
|
||||
|
||||
const zPrompt = z.array(zPromptItem);
|
||||
const zPromptInputs = z.record(zPromptInputItem);
|
||||
|
||||
const zExtraPngInfo = z
|
||||
.object({
|
||||
@@ -26,26 +26,32 @@ const zExtraData = z.object({
|
||||
});
|
||||
const zOutputsToExecute = z.array(zNodeId);
|
||||
|
||||
const zMessageDetailBase = z.object({
|
||||
prompt_id: zPromptId,
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
const zExecutionStartMessage = z.tuple([
|
||||
z.literal("execution_start"),
|
||||
z.object({
|
||||
prompt_id: zPromptId,
|
||||
}),
|
||||
zMessageDetailBase,
|
||||
]);
|
||||
|
||||
const zExecutionSuccessMessage = z.tuple([
|
||||
z.literal("execution_success"),
|
||||
zMessageDetailBase,
|
||||
]);
|
||||
|
||||
const zExecutionCachedMessage = z.tuple([
|
||||
z.literal("execution_cached"),
|
||||
z.object({
|
||||
prompt_id: zPromptId,
|
||||
zMessageDetailBase.extend({
|
||||
nodes: z.array(zNodeId),
|
||||
}),
|
||||
]);
|
||||
|
||||
const zExecutionInterruptedMessage = z.tuple([
|
||||
z.literal("execution_interrupted"),
|
||||
z.object({
|
||||
zMessageDetailBase.extend({
|
||||
// InterruptProcessingException
|
||||
prompt_id: zPromptId,
|
||||
node_id: zNodeId,
|
||||
node_type: zNodeType,
|
||||
executed: z.array(zNodeId),
|
||||
@@ -54,8 +60,7 @@ const zExecutionInterruptedMessage = z.tuple([
|
||||
|
||||
const zExecutionErrorMessage = z.tuple([
|
||||
z.literal("execution_error"),
|
||||
z.object({
|
||||
prompt_id: zPromptId,
|
||||
zMessageDetailBase.extend({
|
||||
node_id: zNodeId,
|
||||
node_type: zNodeType,
|
||||
executed: z.array(zNodeId),
|
||||
@@ -70,6 +75,7 @@ const zExecutionErrorMessage = z.tuple([
|
||||
|
||||
const zStatusMessage = z.union([
|
||||
zExecutionStartMessage,
|
||||
zExecutionSuccessMessage,
|
||||
zExecutionCachedMessage,
|
||||
zExecutionInterruptedMessage,
|
||||
zExecutionErrorMessage,
|
||||
@@ -87,13 +93,15 @@ const zOutput = z.any();
|
||||
const zTaskPrompt = z.tuple([
|
||||
zQueueIndex,
|
||||
zPromptId,
|
||||
zPrompt,
|
||||
zPromptInputs,
|
||||
zExtraData,
|
||||
zOutputsToExecute,
|
||||
]);
|
||||
|
||||
const zRunningTaskItem = z.object({
|
||||
taskType: z.literal("Running"),
|
||||
prompt: zTaskPrompt,
|
||||
// @Deprecated
|
||||
remove: z.object({
|
||||
name: z.literal("Cancel"),
|
||||
cb: z.function(),
|
||||
@@ -101,13 +109,17 @@ const zRunningTaskItem = z.object({
|
||||
});
|
||||
|
||||
const zPendingTaskItem = z.object({
|
||||
taskType: z.literal("Pending"),
|
||||
prompt: zTaskPrompt,
|
||||
});
|
||||
|
||||
const zTaskOutput = z.record(zNodeId, zOutput);
|
||||
|
||||
const zHistoryTaskItem = z.object({
|
||||
taskType: z.literal("History"),
|
||||
prompt: zTaskPrompt,
|
||||
status: zStatus.optional(),
|
||||
outputs: z.record(zNodeId, zOutput),
|
||||
outputs: zTaskOutput,
|
||||
});
|
||||
|
||||
const zTaskItem = z.union([
|
||||
@@ -116,6 +128,17 @@ const zTaskItem = z.union([
|
||||
zHistoryTaskItem,
|
||||
]);
|
||||
|
||||
const zTaskType = z.union([
|
||||
z.literal("Running"),
|
||||
z.literal("Pending"),
|
||||
z.literal("History"),
|
||||
]);
|
||||
|
||||
export type TaskType = z.infer<typeof zTaskType>;
|
||||
export type TaskPrompt = z.infer<typeof zTaskPrompt>;
|
||||
export type TaskStatus = z.infer<typeof zStatus>;
|
||||
export type TaskOutput = z.infer<typeof zTaskOutput>;
|
||||
|
||||
// `/queue`
|
||||
export type RunningTaskItem = z.infer<typeof zRunningTaskItem>;
|
||||
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>;
|
||||
@@ -123,7 +146,17 @@ export type PendingTaskItem = z.infer<typeof zPendingTaskItem>;
|
||||
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>;
|
||||
export type TaskItem = z.infer<typeof zTaskItem>;
|
||||
|
||||
// TODO: validate `/history` `/queue` API endpoint responses.
|
||||
export function validateTaskItem(taskItem: unknown) {
|
||||
const result = zTaskItem.safeParse(taskItem);
|
||||
if (!result.success) {
|
||||
const zodError = fromZodError(result.error);
|
||||
// TODO accept a callback to report error.
|
||||
console.warn(
|
||||
`Invalid TaskItem: ${JSON.stringify(taskItem)}\n${zodError.message}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function inputSpec(
|
||||
spec: [ZodType, ZodType],
|
||||
|
||||
Reference in New Issue
Block a user