mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
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:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<SplitterPanel :size="100">
|
||||
<Splitter
|
||||
class="splitter-overlay"
|
||||
class="splitter-overlay max-w-full"
|
||||
layout="vertical"
|
||||
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user