mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 17:10:06 +00:00
Show text progress messages on executing nodes (#3824)
This commit is contained in:
53
src/components/graph/widgets/TextPreviewWidget.vue
Normal file
53
src/components/graph/widgets/TextPreviewWidget.vue
Normal 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>
|
||||||
53
src/composables/node/useNodeProgressText.ts
Normal file
53
src/composables/node/useNodeProgressText.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/composables/widgets/useProgressTextWidget.ts
Normal file
35
src/composables/widgets/useProgressTextWidget.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -82,6 +82,11 @@ const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
|
|||||||
current_outputs: z.any()
|
current_outputs: z.any()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const zProgressTextWsMessage = z.object({
|
||||||
|
nodeId: zNodeId,
|
||||||
|
text: z.string()
|
||||||
|
})
|
||||||
|
|
||||||
const zTerminalSize = z.object({
|
const zTerminalSize = z.object({
|
||||||
cols: z.number(),
|
cols: z.number(),
|
||||||
row: z.number()
|
row: z.number()
|
||||||
@@ -114,6 +119,7 @@ export type ExecutionInterruptedWsMessage = z.infer<
|
|||||||
>
|
>
|
||||||
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
|
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
|
||||||
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
|
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
|
||||||
|
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||||
// End of ws messages
|
// End of ws messages
|
||||||
|
|
||||||
const zPromptInputItem = z.object({
|
const zPromptInputItem = z.object({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
LogsRawResponse,
|
LogsRawResponse,
|
||||||
LogsWsMessage,
|
LogsWsMessage,
|
||||||
PendingTaskItem,
|
PendingTaskItem,
|
||||||
|
ProgressTextWsMessage,
|
||||||
ProgressWsMessage,
|
ProgressWsMessage,
|
||||||
PromptResponse,
|
PromptResponse,
|
||||||
RunningTaskItem,
|
RunningTaskItem,
|
||||||
@@ -101,6 +102,7 @@ interface BackendApiCalls {
|
|||||||
logs: LogsWsMessage
|
logs: LogsWsMessage
|
||||||
/** Binary preview/progress data */
|
/** Binary preview/progress data */
|
||||||
b_preview: Blob
|
b_preview: Blob
|
||||||
|
progress_text: ProgressTextWsMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dictionary of all api calls */
|
/** Dictionary of all api calls */
|
||||||
@@ -399,12 +401,21 @@ export class ComfyApi extends EventTarget {
|
|||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
const view = new DataView(event.data)
|
const view = new DataView(event.data)
|
||||||
const eventType = view.getUint32(0)
|
const eventType = view.getUint32(0)
|
||||||
const imageType = view.getUint32(4)
|
|
||||||
const imageData = event.data.slice(8)
|
|
||||||
|
|
||||||
let imageMime
|
let imageMime
|
||||||
switch (eventType) {
|
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:
|
case 1:
|
||||||
|
const imageType = view.getUint32(4)
|
||||||
|
const imageData = event.data.slice(8)
|
||||||
switch (imageType) {
|
switch (imageType) {
|
||||||
case 2:
|
case 2:
|
||||||
imageMime = 'image/png'
|
imageMime = 'image/png'
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||||
import type {
|
import type {
|
||||||
ExecutedWsMessage,
|
ExecutedWsMessage,
|
||||||
ExecutionCachedWsMessage,
|
ExecutionCachedWsMessage,
|
||||||
ExecutionErrorWsMessage,
|
ExecutionErrorWsMessage,
|
||||||
ExecutionStartWsMessage,
|
ExecutionStartWsMessage,
|
||||||
NodeError,
|
NodeError,
|
||||||
|
ProgressTextWsMessage,
|
||||||
ProgressWsMessage
|
ProgressWsMessage
|
||||||
} from '@/schemas/apiSchema'
|
} from '@/schemas/apiSchema'
|
||||||
import type {
|
import type {
|
||||||
@@ -103,6 +105,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
handleExecutionError as EventListener
|
handleExecutionError as EventListener
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
api.addEventListener('progress_text', handleProgressText as EventListener)
|
||||||
|
|
||||||
function unbindExecutionEvents() {
|
function unbindExecutionEvents() {
|
||||||
api.removeEventListener(
|
api.removeEventListener(
|
||||||
@@ -121,6 +124,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
'execution_error',
|
'execution_error',
|
||||||
handleExecutionError as EventListener
|
handleExecutionError as EventListener
|
||||||
)
|
)
|
||||||
|
api.removeEventListener(
|
||||||
|
'progress_text',
|
||||||
|
handleProgressText as EventListener
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||||
@@ -177,6 +184,16 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
lastExecutionError.value = e.detail
|
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({
|
function storePrompt({
|
||||||
nodes,
|
nodes,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -472,3 +472,33 @@ export function formatMetronomeCurrency(
|
|||||||
export function usdToMicros(usd: number): number {
|
export function usdToMicros(usd: number): number {
|
||||||
return Math.round(usd * 1_000_000)
|
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 />')
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user