Show text progress messages on executing nodes (#3824)

This commit is contained in:
Christian Byrne
2025-05-10 13:10:58 -07:00
committed by GitHub
parent 4cc6a15fde
commit 992c2ba822
7 changed files with 207 additions and 2 deletions

View File

@@ -0,0 +1,53 @@
<template>
<div
class="relative w-full text-xs min-h-[28px] max-h-[200px] rounded-lg px-4 py-2 overflow-y-auto"
>
<div class="flex items-center gap-2">
<div class="flex-1 break-all flex items-center gap-2">
<span v-html="formattedText"></span>
<Skeleton v-if="isParentNodeExecuting" class="!flex-1 !h-4" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { NodeId } from '@comfyorg/litegraph'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref, watch } from 'vue'
import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
defineProps<{
widget?: object
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
let executingNodeId: NodeId | null = null
onMounted(() => {
executingNodeId = executionStore.executingNodeId
})
// Watch for either a new node has starting execution or overall execution ending
const stopWatching = watch(
[() => executionStore.executingNode, () => executionStore.isIdle],
() => {
if (
executionStore.isIdle ||
(executionStore.executingNode &&
executionStore.executingNode.id !== executingNodeId)
) {
isParentNodeExecuting.value = false
stopWatching()
}
if (!executingNodeId) {
executingNodeId = executionStore.executingNodeId
}
}
)
</script>

View File

@@ -0,0 +1,53 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { useTextPreviewWidget } from '@/composables/widgets/useProgressTextWidget'
const TEXT_PREVIEW_WIDGET_NAME = '$$node-text-preview'
/**
* Composable for handling node text previews
*/
export function useNodeProgressText() {
const textPreviewWidget = useTextPreviewWidget()
const findTextPreviewWidget = (node: LGraphNode) =>
node.widgets?.find((w) => w.name === TEXT_PREVIEW_WIDGET_NAME)
const addTextPreviewWidget = (node: LGraphNode) =>
textPreviewWidget(node, {
name: TEXT_PREVIEW_WIDGET_NAME,
type: 'progressText'
})
/**
* Shows text preview for a node
* @param node The graph node to show the preview for
*/
function showTextPreview(node: LGraphNode, text: string) {
const widget = findTextPreviewWidget(node) ?? addTextPreviewWidget(node)
widget.value = text
node.setDirtyCanvas?.(true)
}
/**
* Removes text preview from a node
* @param node The graph node to remove the preview from
*/
function removeTextPreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === TEXT_PREVIEW_WIDGET_NAME
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showTextPreview,
removeTextPreview
}
}

View File

@@ -0,0 +1,35 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
const PADDING = 16
export const useTextPreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<string | object>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => 42 + PADDING
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -82,6 +82,11 @@ const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
current_outputs: z.any()
})
const zProgressTextWsMessage = z.object({
nodeId: zNodeId,
text: z.string()
})
const zTerminalSize = z.object({
cols: z.number(),
row: z.number()
@@ -114,6 +119,7 @@ export type ExecutionInterruptedWsMessage = z.infer<
>
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
// End of ws messages
const zPromptInputItem = z.object({

View File

@@ -14,6 +14,7 @@ import type {
LogsRawResponse,
LogsWsMessage,
PendingTaskItem,
ProgressTextWsMessage,
ProgressWsMessage,
PromptResponse,
RunningTaskItem,
@@ -101,6 +102,7 @@ interface BackendApiCalls {
logs: LogsWsMessage
/** Binary preview/progress data */
b_preview: Blob
progress_text: ProgressTextWsMessage
}
/** Dictionary of all api calls */
@@ -399,12 +401,21 @@ export class ComfyApi extends EventTarget {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data)
const eventType = view.getUint32(0)
const imageType = view.getUint32(4)
const imageData = event.data.slice(8)
let imageMime
switch (eventType) {
case 3:
const decoder = new TextDecoder()
const data = event.data.slice(4)
const nodeIdLength = view.getUint32(4)
this.dispatchCustomEvent('progress_text', {
nodeId: decoder.decode(data.slice(4, 4 + nodeIdLength)),
text: decoder.decode(data.slice(4 + nodeIdLength))
})
break
case 1:
const imageType = view.getUint32(4)
const imageData = event.data.slice(8)
switch (imageType) {
case 2:
imageMime = 'image/png'

View File

@@ -1,12 +1,14 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type {
ExecutedWsMessage,
ExecutionCachedWsMessage,
ExecutionErrorWsMessage,
ExecutionStartWsMessage,
NodeError,
ProgressTextWsMessage,
ProgressWsMessage
} from '@/schemas/apiSchema'
import type {
@@ -103,6 +105,7 @@ export const useExecutionStore = defineStore('execution', () => {
handleExecutionError as EventListener
)
}
api.addEventListener('progress_text', handleProgressText as EventListener)
function unbindExecutionEvents() {
api.removeEventListener(
@@ -121,6 +124,10 @@ export const useExecutionStore = defineStore('execution', () => {
'execution_error',
handleExecutionError as EventListener
)
api.removeEventListener(
'progress_text',
handleProgressText as EventListener
)
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -177,6 +184,16 @@ export const useExecutionStore = defineStore('execution', () => {
lastExecutionError.value = e.detail
}
function handleProgressText(e: CustomEvent<ProgressTextWsMessage>) {
const { nodeId, text } = e.detail
if (!text || !nodeId) return
const node = app.graph.getNodeById(nodeId)
if (!node) return
useNodeProgressText().showTextPreview(node, text)
}
function storePrompt({
nodes,
id,

View File

@@ -472,3 +472,33 @@ export function formatMetronomeCurrency(
export function usdToMicros(usd: number): number {
return Math.round(usd * 1_000_000)
}
/**
* Converts URLs in a string to HTML links.
* @param text - The string to convert
* @returns The string with URLs converted to HTML links
* @example
* linkifyHtml('Visit https://example.com for more info') // returns 'Visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="text-primary-400 hover:underline">https://example.com</a> for more info'
*/
export function linkifyHtml(text: string): string {
if (!text) return ''
const urlRegex =
/(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%?=~_|])|(\bwww\.[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%?=~_|])/gi
return text.replace(urlRegex, (_match, p1, _p2, p3) => {
const url = p1 || p3
const href = p3 ? `http://${url}` : url
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="text-primary-400 hover:underline">${url}</a>`
})
}
/**
* Converts newline characters to HTML <br> tags.
* @param text - The string to convert
* @returns The string with newline characters converted to <br> tags
* @example
* nl2br('Hello\nWorld') // returns 'Hello<br />World'
*/
export function nl2br(text: string): string {
if (!text) return ''
return text.replace(/\n/g, '<br />')
}