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:
Chenlei Hu
2024-07-22 10:15:41 -04:00
committed by GitHub
parent 1521cd47c8
commit 65740a30c5
27 changed files with 938 additions and 349 deletions

View File

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

View 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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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"));
},

View File

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

View File

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

View File

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

View File

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