// PromptManager/web/prompt_manager.js import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js')); const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js')); const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js')); const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']); // ============================================================================ // Top Bar Button Integration (similar to rgthree-comfy approach) // ============================================================================ let pmButtonGroup = null; /** * Escape HTML special characters to prevent XSS in innerHTML contexts. */ function escapeHtml(text) { if (text == null) return ''; const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; } /** * Split node search input into free text and tag filters (#150). * `tag:portrait tag:"film grain" sunset` -> { text: "sunset", tags: ["portrait", "film grain"] } * @param {string} input - Raw search_text widget value * @returns {{text: string, tags: string[]}} */ function parseSearchQuery(input) { const tags = []; const text = (input || "") .replace(/\btag:(?:"([^"]+)"|(\S+))/gi, (_, quoted, bare) => { tags.push((quoted || bare).trim()); return " "; }) .replace(/\s+/g, " ") .trim(); return { text, tags: tags.filter(Boolean) }; } /** * Create a DOM element with optional attributes and children * @param {string} tag - HTML tag name * @param {Object} attrs - Attributes and properties * @returns {HTMLElement} */ function $el(tag, attrs = {}) { const element = document.createElement(tag); for (const [key, value] of Object.entries(attrs)) { if (key === "children") { if (Array.isArray(value)) { value.forEach(child => child && element.appendChild(child)); } else if (value) { element.appendChild(value); } } else if (key === "child") { if (value) element.appendChild(value); } else if (key === "text") { element.textContent = value; } else if (key === "html") { element.innerHTML = value; } else if (key === "style" && typeof value === "object") { Object.assign(element.style, value); } else if (key === "classList") { element.className = value; } else if (key.startsWith("on") && typeof value === "function") { element.addEventListener(key.slice(2).toLowerCase(), value); } else { element.setAttribute(key, value); } } return element; } /** * Add PromptManager button to the top bar */ function addPMTopBarButton() { // Remove existing button group if present if (pmButtonGroup?.parentElement) { pmButtonGroup.parentElement.removeChild(pmButtonGroup); pmButtonGroup = null; } // Check if app.menu exists (new frontend) if (!app.menu?.settingsGroup?.element) { // New frontend menu not found, try legacy approach addPMTopBarButtonLegacy(); return; } // Create button group container pmButtonGroup = $el("div", { classList: "comfyui-button-group promptmanager-top-button-group", style: { display: "flex", gap: "4px", marginRight: "8px", } }); // Create PM button const pmButton = $el("button", { classList: "comfyui-button comfyui-menu-mobile-collapse primary", title: "PromptManager - Open Admin Dashboard", style: { display: "flex", alignItems: "center", justifyContent: "center", padding: "4px 10px", background: "#14b8a6", color: "white", border: "none", borderRadius: "4px", fontWeight: "bold", fontSize: "12px", cursor: "pointer", transition: "background 0.2s", }, onclick: () => { window.open(api.fileURL("/prompt_manager/admin"), "_blank"); }, onmouseenter: (e) => { e.target.style.background = "#0d9488"; }, onmouseleave: (e) => { e.target.style.background = "#14b8a6"; }, text: "PM" }); pmButtonGroup.appendChild(pmButton); // Insert before the settings group app.menu.settingsGroup.element.before(pmButtonGroup); } /** * Legacy approach for older ComfyUI versions */ function addPMTopBarButtonLegacy() { // Try multiple selectors for different ComfyUI versions const topBar = document.querySelector(".comfyui-menu") || document.querySelector(".comfy-menu") || document.querySelector("#comfy-main-menu"); if (!topBar) { // Retry after a short delay if UI not loaded yet setTimeout(addPMTopBarButtonLegacy, 500); return; } // Check if button already exists if (document.getElementById("pm-toolbar-btn")) { return; } const btn = document.createElement("button"); btn.id = "pm-toolbar-btn"; btn.innerText = "PM"; btn.title = "Open PromptManager Admin Dashboard"; // Teal button styling btn.style.cssText = ` margin-left: 8px; padding: 4px 12px; background: #14b8a6; color: white; border: none; border-radius: 6px; font-weight: bold; font-size: 12px; cursor: pointer; transition: background 0.2s; `; btn.onmouseenter = () => btn.style.background = "#0d9488"; btn.onmouseleave = () => btn.style.background = "#14b8a6"; btn.onclick = () => { window.open(api.fileURL("/prompt_manager/admin"), "_blank"); }; topBar.appendChild(btn); } // ============================================================================ // Extension Registration // ============================================================================ /** * CRITICAL FIX: Sync all PromptManager widget values from DOM * This must be called before any serialization happens */ function syncPromptManagerWidgets() { const graph = app.graph; if (!graph) return; const nodes = graph._nodes || graph.nodes || []; for (const node of nodes) { if (promptManagerTypes.has(node.type)) { if (node.widgets && node.widgets.length > 0) { if (!node.widgets_values) { node.widgets_values = []; } node.widgets.forEach((widget, idx) => { if (widget) { // Skip widgets whose input is connected to another node — // the connected value takes precedence over the DOM widget value const isConnected = node.inputs?.some( i => i.name === widget.name && i.link != null ); if (isConnected) return; // For non-connected widgets, inputEl.value is the true source const actualValue = widget.inputEl ? widget.inputEl.value : widget.value; if (actualValue !== undefined) { node.widgets_values[idx] = actualValue; // Also sync widget.value if (widget.inputEl && widget.value !== actualValue) { widget.value = actualValue; } } } }); } } } } app.registerExtension({ name: "PromptManager.UI", async setup() { installPromptBridge(app, { baseUrl: api.fileURL('/prompt_manager/') }); // Add the top bar button after a short delay to ensure UI is ready setTimeout(() => { addPMTopBarButton(); }, 100); // CRITICAL: Wrap app.graphToPrompt to sync widget values BEFORE serialization // This runs before any other extension's wrapper can access stale data const originalGraphToPrompt = app.graphToPrompt.bind(app); app.graphToPrompt = async function(...args) { syncPromptManagerWidgets(); return originalGraphToPrompt(...args); }; // CRITICAL FIX: Intercept api.queuePrompt to fix prompt data at the LAST moment // This catches any cached/stale data that other extensions might have passed through // NOTE: The third parameter (...rest) carries partialExecutionTargets and previewMethod // which ComfyUI uses for branch execution. Dropping it breaks partial execution. const originalQueuePrompt = api.queuePrompt.bind(api); api.queuePrompt = async function(number, { output, workflow }, ...rest) { // Find PromptManager nodes in the output and fix their text values const graph = app.graph; if (graph && output) { const nodes = graph._nodes || graph.nodes || []; for (const node of nodes) { if (promptManagerTypes.has(node.type) && output[node.id]) { const outputNode = output[node.id]; for (const name of node.type === 'PromptManagerPair' ? ['text','negative_text'] : ['text']) { if (!outputNode.inputs || Array.isArray(outputNode.inputs[name]) || node.inputs?.some(input => input.name === name && input.link != null)) continue; const widget = node.widgets?.find(widget => widget.name === name); if (widget?.inputEl) outputNode.inputs[name] = widget.inputEl.value; } } } } return originalQueuePrompt(number, { output, workflow }, ...rest); }; }, /** * CRITICAL: Also hook beforeQueuePrompt as a backup sync point */ async beforeQueuePrompt(promptInfo) { syncPromptManagerWidgets(); }, async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === 'PromptManagerFocused' || nodeData.name === 'PromptManagerSplit' || nodeData.name === 'PromptManagerPair') { const install = nodeData.name === 'PromptManagerPair' ? installPairNode : nodeData.name === 'PromptManagerSplit' ? installSplitNode : installFocusedNode; install(nodeType, app, api, parseSearchQuery); return; } if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") { // Store original methods const onNodeCreated = nodeType.prototype.onNodeCreated; // Override onNodeCreated to add custom UI nodeType.prototype.onNodeCreated = function () { if (onNodeCreated) { onNodeCreated.apply(this, arguments); } // CRITICAL FIX: Ensure text widget always returns current value during serialization // This fixes the issue where graphToPrompt reads stale cached values const self = this; this._textSyncTimer = setTimeout(() => { if (self._classicScope?.signal.aborted) return; const textWidget = self.widgets?.find((w) => w.name === "text"); if (textWidget) { // Store reference to the widget for the closure const widgetRef = textWidget; const originalSerializeValue = textWidget.serializeValue; // Override serializeValue - use arrow function to capture widgetRef textWidget.serializeValue = (nodeId, widgetIndex) => { // Always return the current DOM value using our stored reference if (widgetRef.inputEl) { const currentValue = widgetRef.inputEl.value; // Sync widget.value as well widgetRef.value = currentValue; return currentValue; } return widgetRef.value; }; // Add aggressive sync listeners for all value change events if (textWidget.inputEl) { const syncValue = function() { const currentVal = textWidget.inputEl.value; textWidget.value = currentVal; // Also update widgets_values array const widgetIndex = self.widgets?.indexOf(textWidget); if (self.widgets_values && widgetIndex >= 0) { self.widgets_values[widgetIndex] = currentVal; } }; // Sync on every keystroke const listen = self._classicScope ? (event) => self._classicScope.listen(textWidget.inputEl, event, syncValue) : (event) => textWidget.inputEl.addEventListener(event, syncValue); listen('input'); // Sync on change (for paste, autocomplete, etc.) listen('change'); // Sync when focus leaves the field listen('blur'); // Sync on keyup as backup listen('keyup'); // Initial sync syncValue(); } } }, 50); // Shorter delay to beat other extensions // Initialize properties for search state (non-serialized runtime state) this.properties = this.properties || {}; this.properties.resultTimeout = 3; // Default 3 seconds this.properties.showTestButton = false; // Default hide test button this.properties.webuiDisplayMode = "popup"; // Default popup mode // Runtime-only state (not serialized to workflow) this._searchResults = []; this._selectedPromptIndex = -1; this.resultHideTimer = null; // Store node type for conditional behavior this.nodeTypeName = nodeData.name; // Load settings from API this.loadSettings(); // Create DOM widget container with unique ID for scoped styling const container = document.createElement("div"); const uniqueId = `pm-${Math.random().toString(36).substring(2, 9)}`; container.id = uniqueId; // Add inline styles for perfect sizing like KikoLocalImageLoader const styleSheet = document.createElement("style"); styleSheet.textContent = ` #${uniqueId} { width: 100%; height: 100%; padding: 8px; background-color: ${nodeData.name === 'PromptManager' ? 'var(--ca-surface)' : '#2a2a2a'}; border-radius: 4px; box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; } #${uniqueId} > div { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 8px; } `; document.head.appendChild(styleSheet); // Store style reference for cleanup this._styleSheet = styleSheet; this.createSearchUI(container); // Add as DOM widget with proper configuration this.searchWidget = this.addDOMWidget( "prompt_manager_search_ui", "div", container, { serialize: false, // Don't save UI state } ); // Set initial node size, but preserve user resizes if (!this._userHasResized) { // For nodes with text widgets, we need more height const baseHeight = 400; const widgetHeight = this.widgets ? this.widgets.length * 30 : 0; this.size = [600, baseHeight + widgetHeight]; // Dynamic size based on widgets this.setSize(this.size); // IMPORTANT: Actually apply the size } // Hook into resize to track user changes const originalOnResize = this.onResize; this.onResize = function (size) { this._userHasResized = true; if (originalOnResize) { originalOnResize.call(this, size); } }; // Hook into serialization to preserve resize flag, sync widget values, and prevent runtime data from being saved const originalSerialize = this.serialize; this.serialize = function () { // Sync widget values BEFORE serialization, but only for non-connected widgets if (this.widgets && this.widgets.length > 0) { if (!this.widgets_values) { this.widgets_values = []; } this.widgets.forEach((widget, idx) => { if (widget) { // Skip connected widgets — their value comes from the link const isConnected = self.inputs?.some( i => i.name === widget.name && i.link != null ); if (isConnected) return; const actualValue = widget.inputEl ? widget.inputEl.value : widget.value; if (actualValue !== undefined) { this.widgets_values[idx] = actualValue; if (widget.inputEl && widget.value !== actualValue) { widget.value = actualValue; } } } }); } const data = originalSerialize ? originalSerialize.call(this) : {}; data._userHasResized = this._userHasResized; // Ensure widgets_values is included in serialization if (this.widgets_values) { data.widgets_values = [...this.widgets_values]; } // Ensure search results are never saved to workflow if (data.properties && data.properties.searchResults) { delete data.properties.searchResults; } if (data.properties && data.properties.selectedPromptIndex) { delete data.properties.selectedPromptIndex; } return data; }; // Hook into configure to restore resize flag const originalConfigure = this.configure; this.configure = function (data) { if (originalConfigure) { originalConfigure.call(this, data); } if (data._userHasResized) { this._userHasResized = data._userHasResized; } }; this.setDirtyCanvas(true, true); }; // Override onRemoved to cleanup styles const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { // Cleanup styles when node is removed if (this._styleSheet && this._styleSheet.parentNode) { this._styleSheet.parentNode.removeChild(this._styleSheet); } // Call original onRemoved if it exists if (onRemoved) { onRemoved.apply(this, arguments); } }; // Method to create search UI elements nodeType.prototype.createSearchUI = function (container) { container.innerHTML = ""; // Main wrapper to match KikoLocalImageLoader structure const wrapper = document.createElement("div"); wrapper.style.width = "100%"; wrapper.style.height = "100%"; wrapper.style.display = "flex"; wrapper.style.flexDirection = "column"; wrapper.style.gap = "8px"; // Search controls section const searchSection = document.createElement("div"); searchSection.style.display = "flex"; searchSection.style.flexDirection = "column"; searchSection.style.height = "100%"; searchSection.style.gap = "8px"; // Search buttons row const buttonRow = document.createElement("div"); buttonRow.style.display = "flex"; buttonRow.style.gap = "8px"; buttonRow.style.flexShrink = "0"; // Don't shrink buttons // Search button const searchButton = document.createElement("button"); searchButton.textContent = "🔍 Search"; searchButton.style.flex = "1"; searchButton.style.padding = "6px 12px"; searchButton.style.backgroundColor = "#4a90e2"; searchButton.style.color = "white"; searchButton.style.border = "none"; searchButton.style.borderRadius = "4px"; searchButton.style.cursor = "pointer"; searchButton.style.fontSize = "12px"; searchButton.addEventListener("click", () => this.performSearch()); buttonRow.appendChild(searchButton); // Recent prompts button const recentButton = document.createElement("button"); recentButton.textContent = "📋 Recent"; recentButton.style.flex = "1"; recentButton.style.padding = "6px 12px"; recentButton.style.backgroundColor = "#7b68ee"; recentButton.style.color = "white"; recentButton.style.border = "none"; recentButton.style.borderRadius = "4px"; recentButton.style.cursor = "pointer"; recentButton.style.fontSize = "12px"; recentButton.addEventListener("click", () => this.loadRecentPrompts()); buttonRow.appendChild(recentButton); // Web UI button const webUIButton = document.createElement("button"); webUIButton.textContent = "🌐 Web UI"; webUIButton.style.flex = "1"; webUIButton.style.padding = "6px 12px"; webUIButton.style.backgroundColor = "#50c878"; webUIButton.style.color = "white"; webUIButton.style.border = "none"; webUIButton.style.borderRadius = "4px"; webUIButton.style.cursor = "pointer"; webUIButton.style.fontSize = "12px"; webUIButton.addEventListener("click", () => this.openWebInterface()); buttonRow.appendChild(webUIButton); searchSection.appendChild(buttonRow); // Add test API button for debugging (only if enabled in settings) if (this.properties.showTestButton) { const testRow = document.createElement("div"); testRow.style.marginBottom = "8px"; const testButton = document.createElement("button"); testButton.textContent = "🔧 Test API"; testButton.style.width = "100%"; testButton.style.padding = "4px 8px"; testButton.style.backgroundColor = "#666"; testButton.style.color = "white"; testButton.style.border = "none"; testButton.style.borderRadius = "3px"; testButton.style.cursor = "pointer"; testButton.style.fontSize = "10px"; testButton.addEventListener("click", () => this.testApiConnection()); testRow.appendChild(testButton); searchSection.appendChild(testRow); } // Results section const resultsSection = document.createElement("div"); resultsSection.style.flex = "1"; // Take remaining space resultsSection.style.minHeight = "100px"; // Minimum height resultsSection.style.overflowY = "auto"; resultsSection.style.border = "1px solid #444"; resultsSection.style.borderRadius = "4px"; resultsSection.style.backgroundColor = "#333"; resultsSection.style.padding = "8px"; resultsSection.style.fontSize = "11px"; resultsSection.style.fontFamily = "monospace"; resultsSection.innerHTML = '