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", "@comfyorg/litegraph": "^0.8.24",
"@primevue/themes": "^4.0.5", "@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0", "@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.4", "axios": "^1.7.4",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fuse.js": "^7.0.0", "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": { "node_modules/abab": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",

View File

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

View File

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

View File

@@ -1,61 +1,104 @@
<template> <template>
<div class="p-terminal rounded-none h-full w-full"> <div class="relative h-full w-full bg-black">
<ScrollPanel class="h-full w-full" ref="scrollPanelRef"> <p v-if="errorMessage" class="p-4 text-center">{{ errorMessage }}</p>
<pre class="px-4 whitespace-pre-wrap">{{ log }}</pre> <ProgressSpinner
</ScrollPanel> 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> </div>
</template> </template>
<script setup lang="ts"> <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 { 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 errorMessage = ref('')
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null) const loading = ref(true)
/** const terminalEl = ref<HTMLDivElement>()
* Whether the user has scrolled to the bottom of the terminal. const fitAddon = new FitAddon()
* This is used to prevent the terminal from scrolling to the bottom const terminal = new Terminal({
* when new logs are fetched. convertEol: true
*/ })
const scrolledToBottom = ref(false) 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 () => { onMounted(async () => {
const element = scrollPanelRef.value?.$el terminal.open(terminalEl.value)
const scrollContainer = element?.querySelector('.p-scrollpanel-content')
if (scrollContainer) { try {
scrollContainer.addEventListener('scroll', () => { await loadLogEntries()
scrolledToBottom.value = } catch (err) {
scrollContainer.scrollTop + scrollContainer.clientHeight === console.error('Error loading logs', err)
scrollContainer.scrollHeight // On older backends the endpoints wont exist
}) errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return
} }
const scrollToBottom = () => { loading.value = false
if (scrollContainer) { resizeObserver.observe(terminalEl.value)
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
watch(log, () => { await watchLogs()
if (scrolledToBottom.value) {
scrollToBottom()
}
})
const fetchLogs = async () => {
log.value = await api.getLogs()
}
await fetchLogs()
scrollToBottom()
intervalId = window.setInterval(fetchLogs, 500)
}) })
onBeforeUnmount(() => { onUnmounted(() => {
window.clearInterval(intervalId) if (api.clientId) {
api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
resizeObserver.disconnect()
}) })
</script> </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 User,
type Settings, type Settings,
type UserDataFullInfo, type UserDataFullInfo,
validateComfyNodeDef validateComfyNodeDef,
LogsRawResponse
} from '@/types/apiTypes' } from '@/types/apiTypes'
import axios from 'axios' import axios from 'axios'
@@ -202,11 +203,6 @@ class ComfyApi extends EventTarget {
new CustomEvent('status', { detail: msg.data.status }) new CustomEvent('status', { detail: msg.data.status })
) )
break break
case 'progress':
this.dispatchEvent(
new CustomEvent('progress', { detail: msg.data })
)
break
case 'executing': case 'executing':
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('executing', { new CustomEvent('executing', {
@@ -214,29 +210,15 @@ class ComfyApi extends EventTarget {
}) })
) )
break break
case 'progress':
case 'executed': case 'executed':
this.dispatchEvent(
new CustomEvent('executed', { detail: msg.data })
)
break
case 'execution_start': case 'execution_start':
this.dispatchEvent(
new CustomEvent('execution_start', { detail: msg.data })
)
break
case 'execution_success': case 'execution_success':
this.dispatchEvent(
new CustomEvent('execution_success', { detail: msg.data })
)
break
case 'execution_error': case 'execution_error':
this.dispatchEvent(
new CustomEvent('execution_error', { detail: msg.data })
)
break
case 'execution_cached': case 'execution_cached':
case 'logs':
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('execution_cached', { detail: msg.data }) new CustomEvent(msg.type, { detail: msg.data })
) )
break break
default: default:
@@ -720,6 +702,17 @@ class ComfyApi extends EventTarget {
return (await axios.get(this.internalURL('/logs'))).data 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[]>> { async getFolderPaths(): Promise<Record<string, string[]>> {
return (await axios.get(this.internalURL('/folder_paths'))).data return (await axios.get(this.internalURL('/folder_paths'))).data
} }

View File

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

View File

@@ -77,6 +77,23 @@ const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
current_outputs: z.any() 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 StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
export type StatusWsMessage = z.infer<typeof zStatusWsMessage> export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage> export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
@@ -91,6 +108,7 @@ export type ExecutionInterruptedWsMessage = z.infer<
typeof zExecutionInterruptedWsMessage typeof zExecutionInterruptedWsMessage
> >
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage> export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
// End of ws messages // End of ws messages
const zPromptInputItem = z.object({ const zPromptInputItem = z.object({
@@ -520,3 +538,6 @@ export type SystemStats = z.infer<typeof zSystemStats>
export type User = z.infer<typeof zUser> export type User = z.infer<typeof zUser>
export type UserData = z.infer<typeof zUserData> export type UserData = z.infer<typeof zUserData>
export type UserDataFullInfo = z.infer<typeof zUserDataFullInfo> 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>