feat(debug): replace websocket with ComfyUI UI system for metadata display

- Update DebugMetadata node to return metadata via ComfyUI's UI system instead of websocket
- Add new JsonDisplayWidget Vue component for displaying metadata in the UI
- Remove dependency on PromptServer and websocket communication
- Improve error handling with proper UI feedback
- Maintain backward compatibility with existing metadata collection system
This commit is contained in:
Will Miao
2026-01-16 21:29:03 +08:00
parent 4f3c91b307
commit 07d599810d
5 changed files with 273 additions and 211 deletions

View File

@@ -1,15 +1,15 @@
import logging
from server import PromptServer # type: ignore
from ..metadata_collector.metadata_processor import MetadataProcessor
logger = logging.getLogger(__name__)
class DebugMetadata:
NAME = "Debug Metadata (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = "Debug node to verify metadata_processor functionality"
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
@@ -25,21 +25,37 @@ class DebugMetadata:
FUNCTION = "process_metadata"
def process_metadata(self, images, id):
"""
Process metadata from the execution context and return it for UI display.
The metadata is returned via the 'ui' key in the return dict, which triggers
node.onExecuted on the frontend to update the JsonDisplayWidget.
Args:
images: Input images (required for execution flow)
id: Node's unique ID (hidden)
Returns:
Dict with 'result' (empty tuple) and 'ui' (metadata dict for widget display)
"""
try:
# Get the current execution context's metadata
from ..metadata_collector import get_metadata
metadata = get_metadata()
# Use the MetadataProcessor to convert it to JSON string
metadata_json = MetadataProcessor.to_json(metadata, id)
# Send metadata to frontend for display
PromptServer.instance.send_sync("metadata_update", {
"id": id,
"metadata": metadata_json
})
# Use the MetadataProcessor to convert it to dict
metadata_dict = MetadataProcessor.to_dict(metadata, id)
return {
"result": (),
# ComfyUI expects ui values to be lists, wrap the dict in a list
"ui": {"metadata": [metadata_dict]},
}
except Exception as e:
logger.error(f"Error processing metadata: {e}")
return ()
return {
"result": (),
"ui": {"metadata": [{"error": str(e)}]},
}

View File

@@ -0,0 +1,159 @@
<template>
<div class="json-display-widget">
<div class="json-content" ref="contentRef">
<pre v-if="hasMetadata" v-html="highlightedJson"></pre>
<div v-else class="placeholder">No metadata available</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
interface JsonDisplayWidget {
serializeValue?: () => Promise<unknown>
value?: unknown
onSetValue?: (v: unknown) => void
}
const props = defineProps<{
widget: JsonDisplayWidget
node: { id: number; onExecuted?: (output: Record<string, unknown>) => void }
}>()
const metadata = ref<Record<string, unknown> | null>(null)
const hasMetadata = computed(() =>
metadata.value !== null && Object.keys(metadata.value).length > 0
)
const highlightedJson = computed(() => {
if (!metadata.value) return ''
const jsonStr = JSON.stringify(metadata.value, null, 2)
return syntaxHighlight(jsonStr)
})
// Color scheme matching original json_display_widget.js
const colors = {
key: '#6ad6f5', // Light blue for keys
string: '#98c379', // Soft green for strings
number: '#e5c07b', // Amber for numbers
boolean: '#c678dd', // Purple for booleans
null: '#7f848e' // Gray for null
}
function syntaxHighlight(json: string): string {
// Escape HTML entities
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Apply syntax highlighting with regex
return json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
(match) => {
let color = colors.number
if (/^"/.test(match)) {
if (/:$/.test(match)) {
// Key
color = colors.key
match = match.replace(/:$/, '')
return `<span style="color:${color};">${match}</span>:`
} else {
// String value
color = colors.string
}
} else if (/true|false/.test(match)) {
color = colors.boolean
} else if (/null/.test(match)) {
color = colors.null
}
return `<span style="color:${color};">${match}</span>`
}
)
}
onMounted(() => {
// Display-only widget - return null on serialization to avoid saving large metadata
props.widget.serializeValue = async () => null
// Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = (v) => {
if (v && typeof v === 'object') {
metadata.value = v as Record<string, unknown>
}
}
// Restore from saved value if exists (for workflow loading)
if (props.widget.value && typeof props.widget.value === 'object') {
metadata.value = props.widget.value as Record<string, unknown>
}
// Override onExecuted to handle backend UI updates
// Following the pattern from LoraRandomizerWidget.vue
const originalOnExecuted = (props.node as any).onExecuted?.bind(props.node)
;(props.node as any).onExecuted = function(output: any) {
// Update metadata from backend ui return
if (output?.metadata !== undefined) {
let metadataValue = output.metadata
// ComfyUI wraps ui values in arrays, unwrap if needed
if (Array.isArray(metadataValue)) {
metadataValue = metadataValue[0]
}
// If it's a string (JSON), parse it
if (typeof metadataValue === 'string') {
try {
metadataValue = JSON.parse(metadataValue)
} catch (e) {
console.error('[JsonDisplayWidget] Failed to parse JSON:', e)
}
}
metadata.value = metadataValue
}
// Call original onExecuted if it exists
if (originalOnExecuted) {
return originalOnExecuted(output)
}
}
})
</script>
<style scoped>
.json-display-widget {
padding: 8px;
background: rgba(40, 44, 52, 0.6);
border-radius: 6px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
.json-content {
flex: 1;
overflow: auto;
font-family: monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
color: rgba(226, 232, 240, 0.9);
}
.json-content pre {
margin: 0;
padding: 0;
}
.placeholder {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
text-align: center;
padding: 20px 0;
}
</style>

View File

@@ -2,6 +2,7 @@ import { createApp, type App as VueApp } from 'vue'
import PrimeVue from 'primevue/config'
import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue'
import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig } from './composables/types'
const LORA_POOL_WIDGET_MIN_WIDTH = 500
@@ -9,6 +10,8 @@ const LORA_POOL_WIDGET_MIN_HEIGHT = 400
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 510
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
// @ts-ignore - ComfyUI external module
import { app } from '../../../scripts/app.js'
@@ -207,6 +210,72 @@ function createLoraRandomizerWidget(node) {
return { widget }
}
// @ts-ignore
function createJsonDisplayWidget(node) {
const container = document.createElement('div')
container.id = `json-display-widget-${node.id}`
container.style.width = '100%'
container.style.height = '100%'
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.overflow = 'hidden'
forwardMiddleMouseToCanvas(container)
let internalValue: Record<string, unknown> | undefined
const widget = node.addDOMWidget(
'metadata',
'JSON_DISPLAY',
container,
{
getValue() {
return internalValue
},
setValue(v: Record<string, unknown>) {
internalValue = v
if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v)
}
},
serialize: false, // Display-only widget - don't save metadata in workflows
getMinHeight() {
return JSON_DISPLAY_WIDGET_MIN_HEIGHT
}
}
)
const vueApp = createApp(JsonDisplayWidget, {
widget,
node
})
vueApp.use(PrimeVue, {
unstyled: true,
ripple: false
})
vueApp.mount(container)
vueApps.set(node.id + 20000, vueApp) // Offset to avoid collision with other widgets
widget.computeLayoutSize = () => {
const minWidth = JSON_DISPLAY_WIDGET_MIN_WIDTH
const minHeight = JSON_DISPLAY_WIDGET_MIN_HEIGHT
return { minHeight, minWidth }
}
widget.onRemove = () => {
const vueApp = vueApps.get(node.id + 20000)
if (vueApp) {
vueApp.unmount()
vueApps.delete(node.id + 20000)
}
}
return { widget }
}
app.registerExtension({
name: 'LoraManager.VueWidgets',
@@ -238,5 +307,20 @@ app.registerExtension({
return addLorasWidgetCache(node, 'loras', { isRandomizerNode }, callback)
}
}
},
// Add display-only widget to Debug Metadata node
// @ts-ignore
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === 'Debug Metadata (LoraManager)') {
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
// Add the JSON display widget
createJsonDisplayWidget(this)
}
}
}
})

View File

@@ -1,53 +0,0 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { addJsonDisplayWidget } from "./json_display_widget.js";
import { getNodeFromGraph } from "./utils.js";
app.registerExtension({
name: "LoraManager.DebugMetadata",
setup() {
// Add message handler to listen for metadata updates from Python
api.addEventListener("metadata_update", (event) => {
const { id, graph_id: graphId, metadata } = event.detail;
this.handleMetadataUpdate(id, graphId, metadata);
});
},
async nodeCreated(node) {
if (node.comfyClass === "Debug Metadata (LoraManager)") {
// Enable widget serialization
node.serialize_widgets = true;
// Add a widget to display metadata
const jsonWidget = addJsonDisplayWidget(node, "metadata", {
defaultVal: "",
}).widget;
// Store reference to the widget
node.jsonWidget = jsonWidget;
// Restore saved value if exists
if (node.widgets_values && node.widgets_values.length > 0) {
const savedValue = node.widgets_values[0];
if (savedValue) {
jsonWidget.value = savedValue;
}
}
}
},
// Handle metadata updates from Python
handleMetadataUpdate(id, graphId, metadata) {
const node = getNodeFromGraph(graphId, id);
if (!node || node.comfyClass !== "Debug Metadata (LoraManager)") {
console.warn("Node not found or not a DebugMetadata node:", id);
return;
}
if (node.jsonWidget) {
// Update the widget with the received metadata
node.jsonWidget.value = metadata;
}
}
});

View File

@@ -1,144 +0,0 @@
import { forwardMiddleMouseToCanvas } from "./utils.js";
export function addJsonDisplayWidget(node, name, opts) {
// Create container for JSON display
const container = document.createElement("div");
container.className = "comfy-json-display-container";
forwardMiddleMouseToCanvas(container);
// Set initial height
const defaultHeight = 200;
Object.assign(container.style, {
display: "block",
padding: "8px",
backgroundColor: "rgba(40, 44, 52, 0.6)",
borderRadius: "6px",
width: "100%",
boxSizing: "border-box",
overflow: "auto",
overflowY: "scroll",
maxHeight: `${defaultHeight}px`,
fontFamily: "monospace",
fontSize: "12px",
lineHeight: "1.5",
whiteSpace: "pre-wrap",
color: "rgba(226, 232, 240, 0.9)"
});
// Initialize default value
const initialValue = opts?.defaultVal || "";
// Function to format and display JSON content with syntax highlighting
const displayJson = (jsonString, widget) => {
try {
// If string is empty, show placeholder
if (!jsonString || jsonString.trim() === '') {
container.textContent = "No metadata available";
container.style.fontStyle = "italic";
container.style.color = "rgba(226, 232, 240, 0.6)";
container.style.textAlign = "center";
container.style.padding = "20px 0";
return;
}
// Try to parse and pretty-print if it's valid JSON
try {
const jsonObj = JSON.parse(jsonString);
container.innerHTML = syntaxHighlight(JSON.stringify(jsonObj, null, 2));
} catch (e) {
// If not valid JSON, display as-is
container.textContent = jsonString;
}
container.style.fontStyle = "normal";
container.style.textAlign = "left";
container.style.padding = "8px";
} catch (error) {
console.error("Error displaying JSON:", error);
container.textContent = "Error displaying content";
}
};
// Function to add syntax highlighting to JSON
function syntaxHighlight(json) {
// Color scheme
const colors = {
key: "#6ad6f5", // Light blue for keys
string: "#98c379", // Soft green for strings
number: "#e5c07b", // Amber for numbers
boolean: "#c678dd", // Purple for booleans
null: "#7f848e" // Gray for null
};
// Replace JSON syntax with highlighted HTML
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
let cls = 'number';
let color = colors.number;
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
color = colors.key;
// Remove the colon from the key and add it back without color
match = match.replace(/:$/, '');
return '<span style="color:' + color + ';">' + match + '</span>:';
} else {
cls = 'string';
color = colors.string;
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
color = colors.boolean;
} else if (/null/.test(match)) {
cls = 'null';
color = colors.null;
}
return '<span style="color:' + color + ';">' + match + '</span>';
});
}
// Store the value
let widgetValue = initialValue;
// Create widget with DOM Widget API
const widget = node.addDOMWidget(name, "custom", container, {
getValue: function() {
return widgetValue;
},
setValue: function(v) {
widgetValue = v;
displayJson(widgetValue, widget);
},
hideOnZoom: true
});
// Set initial value
widget.value = initialValue;
widget.serializeValue = () => {
return widgetValue;
};
// Update widget when node is resized
const onNodeResize = node.onResize;
node.onResize = function(size) {
if(onNodeResize) {
onNodeResize.call(this, size);
}
// Adjust container height to node height
if(size && size[1]) {
// Reduce the offset to minimize the gap at the bottom
const widgetHeight = Math.min(size[1] - 30, defaultHeight * 2); // Reduced from 80 to 30
container.style.maxHeight = `${widgetHeight}px`;
container.style.setProperty('--comfy-widget-height', `${widgetHeight}px`);
}
};
return { minWidth: 300, minHeight: defaultHeight, widget };
}