Live terminal output (#1347)

* Add live terminal output

* Fix scrolling

* Refactor loading

* Fallback to polling if endpoint fails

* Comment

* Move clientId to executionStore
Refactor types

* Remove polling
This commit is contained in:
pythongosssss
2024-11-08 20:38:21 +00:00
committed by GitHub
parent 0161a670cf
commit 7e0b87dd32
7 changed files with 156 additions and 68 deletions

15
package-lock.json generated
View File

@@ -12,6 +12,8 @@
"@comfyorg/litegraph": "^0.8.24",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.4",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
@@ -4627,6 +4629,19 @@
}
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",

View File

@@ -74,6 +74,8 @@
"@comfyorg/litegraph": "^0.8.24",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.4",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",

View File

@@ -15,7 +15,7 @@
<SplitterPanel :size="100">
<Splitter
class="splitter-overlay"
class="splitter-overlay max-w-full"
layout="vertical"
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
>

View File

@@ -1,61 +1,104 @@
<template>
<div class="p-terminal rounded-none h-full w-full">
<ScrollPanel class="h-full w-full" ref="scrollPanelRef">
<pre class="px-4 whitespace-pre-wrap">{{ log }}</pre>
</ScrollPanel>
<div class="relative h-full w-full bg-black">
<p v-if="errorMessage" class="p-4 text-center">{{ errorMessage }}</p>
<ProgressSpinner
v-else-if="loading"
class="absolute inset-0 flex justify-center items-center h-full z-10"
/>
<div v-show="!loading" class="p-terminal rounded-none h-full w-full p-2">
<div class="h-full" ref="terminalEl"></div>
</div>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import '@xterm/xterm/css/xterm.css'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { api } from '@/scripts/api'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { debounce } from 'lodash'
import ProgressSpinner from 'primevue/progressspinner'
import { useExecutionStore } from '@/stores/executionStore'
import { storeToRefs } from 'pinia'
import { until } from '@vueuse/core'
import { LogEntry, LogsWsMessage, TerminalSize } from '@/types/apiTypes'
const log = ref<string>('')
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
/**
* Whether the user has scrolled to the bottom of the terminal.
* This is used to prevent the terminal from scrolling to the bottom
* when new logs are fetched.
*/
const scrolledToBottom = ref(false)
const errorMessage = ref('')
const loading = ref(true)
const terminalEl = ref<HTMLDivElement>()
const fitAddon = new FitAddon()
const terminal = new Terminal({
convertEol: true
})
terminal.loadAddon(fitAddon)
let intervalId: number = 0
const resizeTerminal = () =>
terminal.resize(terminal.cols, fitAddon.proposeDimensions().rows)
const resizeObserver = new ResizeObserver(debounce(resizeTerminal, 50))
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
if (size) {
terminal.resize(size.cols, fitAddon.proposeDimensions().rows)
}
terminal.write(entries.map((e) => e.m).join(''))
}
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
update(e.detail.entries, e.detail.size)
}
const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries, logs.size)
}
const watchLogs = async () => {
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) {
await until(clientId).not.toBeNull()
}
api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
onMounted(async () => {
const element = scrollPanelRef.value?.$el
const scrollContainer = element?.querySelector('.p-scrollpanel-content')
terminal.open(terminalEl.value)
if (scrollContainer) {
scrollContainer.addEventListener('scroll', () => {
scrolledToBottom.value =
scrollContainer.scrollTop + scrollContainer.clientHeight ===
scrollContainer.scrollHeight
})
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints wont exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return
}
const scrollToBottom = () => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
loading.value = false
resizeObserver.observe(terminalEl.value)
watch(log, () => {
if (scrolledToBottom.value) {
scrollToBottom()
}
})
const fetchLogs = async () => {
log.value = await api.getLogs()
}
await fetchLogs()
scrollToBottom()
intervalId = window.setInterval(fetchLogs, 500)
await watchLogs()
})
onBeforeUnmount(() => {
window.clearInterval(intervalId)
onUnmounted(() => {
if (api.clientId) {
api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
resizeObserver.disconnect()
})
</script>
<style scoped>
:deep(.p-terminal) .xterm {
overflow-x: auto;
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -11,7 +11,8 @@ import {
type User,
type Settings,
type UserDataFullInfo,
validateComfyNodeDef
validateComfyNodeDef,
LogsRawResponse
} from '@/types/apiTypes'
import axios from 'axios'
@@ -202,11 +203,6 @@ class ComfyApi extends EventTarget {
new CustomEvent('status', { detail: msg.data.status })
)
break
case 'progress':
this.dispatchEvent(
new CustomEvent('progress', { detail: msg.data })
)
break
case 'executing':
this.dispatchEvent(
new CustomEvent('executing', {
@@ -214,29 +210,15 @@ class ComfyApi extends EventTarget {
})
)
break
case 'progress':
case 'executed':
this.dispatchEvent(
new CustomEvent('executed', { detail: msg.data })
)
break
case 'execution_start':
this.dispatchEvent(
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 })
)
break
case 'execution_cached':
case 'logs':
this.dispatchEvent(
new CustomEvent('execution_cached', { detail: msg.data })
new CustomEvent(msg.type, { detail: msg.data })
)
break
default:
@@ -720,6 +702,17 @@ class ComfyApi extends EventTarget {
return (await axios.get(this.internalURL('/logs'))).data
}
async getRawLogs(): Promise<LogsRawResponse> {
return (await axios.get(this.internalURL('/logs/raw'))).data
}
async subscribeLogs(enabled: boolean): Promise<void> {
return await axios.patch(this.internalURL('/logs/subscribe'), {
enabled,
clientId: this.clientId
})
}
async getFolderPaths(): Promise<Record<string, string[]>> {
return (await axios.get(this.internalURL('/folder_paths'))).data
}

View File

@@ -8,7 +8,8 @@ import type {
ExecutingWsMessage,
ExecutionCachedWsMessage,
ExecutionStartWsMessage,
ProgressWsMessage
ProgressWsMessage,
StatusWsMessage
} from '@/types/apiTypes'
export interface QueuedPrompt {
@@ -17,6 +18,7 @@ export interface QueuedPrompt {
}
export const useExecutionStore = defineStore('execution', () => {
const clientId = ref<string | null>(null)
const activePromptId = ref<string | null>(null)
const queuedPrompts = ref<Record<string, QueuedPrompt>>({})
const executingNodeId = ref<string | null>(null)
@@ -84,6 +86,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.addEventListener('executed', handleExecuted as EventListener)
api.addEventListener('executing', handleExecuting as EventListener)
api.addEventListener('progress', handleProgress as EventListener)
api.addEventListener('status', handleStatus as EventListener)
}
function unbindExecutionEvents() {
@@ -98,6 +101,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('executed', handleExecuted as EventListener)
api.removeEventListener('executing', handleExecuting as EventListener)
api.removeEventListener('progress', handleProgress as EventListener)
api.removeEventListener('status', handleStatus as EventListener)
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -140,6 +144,15 @@ export const useExecutionStore = defineStore('execution', () => {
_executingNodeProgress.value = e.detail
}
function handleStatus(e: CustomEvent<StatusWsMessage>) {
if (api.clientId) {
clientId.value = api.clientId
// Once we've received the clientId we no longer need to listen
api.removeEventListener('status', handleStatus as EventListener)
}
}
function storePrompt({
nodes,
id,
@@ -167,6 +180,7 @@ export const useExecutionStore = defineStore('execution', () => {
return {
isIdle,
clientId,
activePromptId,
queuedPrompts,
executingNodeId,

View File

@@ -77,6 +77,23 @@ const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
current_outputs: z.any()
})
const zTerminalSize = z.object({
cols: z.number(),
row: z.number()
})
const zLogEntry = z.object({
t: z.string(),
m: z.string()
})
const zLogsWsMessage = z.object({
size: zTerminalSize.optional(),
entries: z.array(zLogEntry)
})
const zLogRawResponse = z.object({
size: zTerminalSize,
entries: z.array(zLogEntry)
})
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
@@ -91,6 +108,7 @@ export type ExecutionInterruptedWsMessage = z.infer<
typeof zExecutionInterruptedWsMessage
>
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
// End of ws messages
const zPromptInputItem = z.object({
@@ -520,3 +538,6 @@ export type SystemStats = z.infer<typeof zSystemStats>
export type User = z.infer<typeof zUser>
export type UserData = z.infer<typeof zUserData>
export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo>
export type TerminalSize = z.infer<typeof zTerminalSize>
export type LogEntry = z.infer<typeof zLogEntry>
export type LogsRawResponse = z.infer<typeof zLogRawResponse>