Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Live terminal output #1347

Merged
merged 8 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
"@comfyorg/litegraph": "^0.8.10",
"@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",
Expand Down
2 changes: 1 addition & 1 deletion src/components/LiteGraphCanvasSplitterOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

<SplitterPanel :size="100">
<Splitter
class="splitter-overlay"
class="splitter-overlay max-w-full"
layout="vertical"
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
>
Expand Down
139 changes: 99 additions & 40 deletions src/components/bottomPanel/tabs/IntegratedTerminal.vue
Original file line number Diff line number Diff line change
@@ -1,61 +1,120 @@
<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">
<ProgressSpinner
v-if="loading"
class="absolute inset-0 flex justify-center items-center h-full z-10"
/>
<div 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)
let intervalId: number
let useFallbackPolling: boolean = false
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)

onMounted(async () => {
const element = scrollPanelRef.value?.$el
const scrollContainer = element?.querySelector('.p-scrollpanel-content')

if (scrollContainer) {
scrollContainer.addEventListener('scroll', () => {
scrolledToBottom.value =
scrollContainer.scrollTop + scrollContainer.clientHeight ===
scrollContainer.scrollHeight
})
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 scrollToBottom = () => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
const loadLogText = async () => {
pythongosssss marked this conversation as resolved.
Show resolved Hide resolved
// Fallback to using string logs
const logs = await api.getLogs()
terminal.clear()
terminal.write(logs)
fitAddon.fit()
}

const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries, logs.size)
}

const watchLogs = async () => {
if (useFallbackPolling) {
intervalId = window.setInterval(loadLogText, 500)
} else {
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) {
console.log('waiting')
await until(clientId).not.toBeNull()
console.log('waited', clientId.value)
}
api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
}

watch(log, () => {
if (scrolledToBottom.value) {
scrollToBottom()
}
})
onMounted(async () => {
terminal.open(terminalEl.value)

const fetchLogs = async () => {
log.value = await api.getLogs()
try {
await loadLogEntries()
} catch {
// On older backends the endpoints wont exist, fallback to poll
useFallbackPolling = true
await loadLogText()
}

await fetchLogs()
scrollToBottom()
intervalId = window.setInterval(fetchLogs, 500)
loading.value = false
resizeObserver.observe(terminalEl.value)

await watchLogs()
})

onBeforeUnmount(() => {
window.clearInterval(intervalId)
onUnmounted(() => {
if (useFallbackPolling) {
window.clearInterval(intervalId)
} else {
if (api.clientId) {
api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
}

resizeObserver.disconnect()
})
</script>

<style>
pythongosssss marked this conversation as resolved.
Show resolved Hide resolved
.p-terminal .xterm {
overflow-x: auto;
}

.p-terminal .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>
39 changes: 16 additions & 23 deletions src/scripts/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
type User,
type Settings,
type UserDataFullInfo,
validateComfyNodeDef
validateComfyNodeDef,
LogsRawResponse
} from '@/types/apiTypes'
import axios from 'axios'

Expand Down Expand Up @@ -202,41 +203,22 @@ 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', {
detail: msg.data.display_node || msg.data.node
})
)
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:
Expand Down Expand Up @@ -714,6 +696,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
}
Expand Down
16 changes: 15 additions & 1 deletion src/stores/executionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import type {
ExecutingWsMessage,
ExecutionCachedWsMessage,
ExecutionStartWsMessage,
ProgressWsMessage
ProgressWsMessage,
StatusWsMessage
} from '@/types/apiTypes'

export interface QueuedPrompt {
Expand All @@ -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)
Expand Down Expand Up @@ -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() {
Expand All @@ -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>) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -167,6 +180,7 @@ export const useExecutionStore = defineStore('execution', () => {

return {
isIdle,
clientId,
activePromptId,
queuedPrompts,
executingNodeId,
Expand Down
21 changes: 21 additions & 0 deletions src/types/apiTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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({
Expand Down Expand Up @@ -516,3 +534,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>
Loading