Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -69,6 +69,8 @@
"@comfyorg/litegraph": "^0.8.7",
"@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
144 changes: 102 additions & 42 deletions src/components/bottomPanel/tabs/IntegratedTerminal.vue
Original file line number Diff line number Diff line change
@@ -1,61 +1,121 @@
<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 { api } from '@/scripts/api'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import '@xterm/xterm/css/xterm.css'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { api, LogEntry, TerminalSize } from '@/scripts/api'
import { onMounted, onUnmounted, ref } from 'vue'
import { debounce } from 'lodash'
import ProgressSpinner from 'primevue/progressspinner'

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 scrollToBottom = () => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
const logReceived = (
e: CustomEvent<{
entries: Array<{ t: string; m: string }>
size: {
rows: number
cols: number
} | null
}>
) => {
update(e.detail.entries, e.detail.size)
}

const loadLogText = async () => {
// 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 {
// It is possible for a user to open the terminal before a clientid is assigned
// subscribe requires this so wait for it
await api.waitForClientId()
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>
.p-terminal .xterm {
overflow-x: auto;
}

.p-terminal .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>
67 changes: 41 additions & 26 deletions src/scripts/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,19 @@ interface QueuePromptRequestBody {
number?: number
}

export interface LogEntry {
t: string
m: string
}

export interface TerminalSize {
cols: number
rows: number
}

class ComfyApi extends EventTarget {
#clientIdBlock: Promise<void>
#clientIdResolve: Function
#registered = new Set()
api_host: string
api_base: string
Expand All @@ -57,6 +69,17 @@ class ComfyApi extends EventTarget {
this.initialClientId = sessionStorage.getItem('clientId')
}

async waitForClientId() {
if (this.clientId) {
return this.clientId
}
if (!this.#clientIdBlock) {
this.#clientIdBlock = new Promise((res) => (this.#clientIdResolve = res))
}
await this.#clientIdBlock
return this.clientId
}

internalURL(route: string): string {
return this.api_base + '/internal' + route
}
Expand Down Expand Up @@ -198,51 +221,29 @@ class ComfyApi extends EventTarget {
this.clientId = clientId
window.name = clientId // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem('clientId', clientId) // store in session storage so duplicate tab can load correct workflow
this.#clientIdResolve?.()
}
this.dispatchEvent(
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':
this.dispatchEvent(
new CustomEvent('execution_cached', { detail: msg.data })
)
break
case 'download_progress':
case 'logs':
this.dispatchEvent(
new CustomEvent('download_progress', { detail: msg.data })
new CustomEvent(msg.type, { detail: msg.data })
)
break
default:
Expand Down Expand Up @@ -747,6 +748,20 @@ class ComfyApi extends EventTarget {
return (await axios.get(this.internalURL('/logs'))).data
}

async getRawLogs(): Promise<{
size: TerminalSize
entries: Array<LogEntry>
}> {
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
Loading