* fix: release 3.2.3 — metadata parsing, null-safe saves, auto-hide, tag search - fix(#75): stop SaveImage patch from writing the last-run prompt into every PromptManager node (negative prompts were saved as copies of the positive) - fix(#75): replace four duplicated metadata parsers with a shared web/js/comfy-metadata.js that traces sampler links (KSampler, CFGGuider / SamplerCustomAdvanced, LoRA chains, ControlNet pass-through, any checkpoint loader) and recovers per-node text for images saved by older versions - fix(#143): treat null JSON body fields as empty in prompt API handlers - fix(#149): keep auto-hide timeout 0 (disabled) instead of falling back to the default; validate result_timeout on save - feat(#150): support tag:name filters in the node search box - docs(#142): scan dialog mentions A1111/Forge metadata support - docs(#144): fix ComfyUI-Lora-Manager links - ci: run JS tests with node --test * fix: bound metadata graph traversal to prevent tab freeze on crafted PNGs Link resolution tried every linked input recursively with only a depth cap, so a PNG whose prompt graph has cycles and fan-out could force ~inputs^25 paths and hang the gallery/metadata page. Each lookup now shares a visited set (each node expanded once) with a hard cap of 500 visits. * fix: read the matching output of multi-output settings nodes KSampler settings linked to a node like SamplerCombo all resolved to its first value, so steps, cfg and sampler showed the sampler name. Scalar resolution now prefers the linked node's input with the same field name. * fix: address review — trust API text, restore workflow-only parsing - Workflow widget text now only replaces API text for metadata showing the pre-3.2.3 corruption signature (2+ PromptManager nodes with identical API text but different widgets). Otherwise the API graph wins, so expanded dynamic prompts and linked text display what actually ran. - Images with only a UI workflow are converted to the API graph shape and traced the same way: prompts, KSamplerAdvanced, UNET/checkpoint loaders, KJNodes Set/Get, Reroute, bypassed and muted nodes.
1180 lines
42 KiB
JavaScript
1180 lines
42 KiB
JavaScript
// PromptManager/web/prompt_manager.js
|
|
|
|
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
|
|
// ============================================================================
|
|
// 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("/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("/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 (node.type === "PromptManager" || node.type === "PromptManagerText") {
|
|
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() {
|
|
// 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 ((node.type === "PromptManager" || node.type === "PromptManagerText") && output[node.id]) {
|
|
const outputNode = output[node.id];
|
|
|
|
// Skip if text input is connected to another node (value is a link reference array)
|
|
if (outputNode.inputs && Array.isArray(outputNode.inputs.text)) {
|
|
continue;
|
|
}
|
|
|
|
// Only sync widget DOM value for non-connected (widget-based) text inputs
|
|
const textWidget = node.widgets?.find(w => w.name === "text");
|
|
if (textWidget && textWidget.inputEl) {
|
|
const currentValue = textWidget.inputEl.value;
|
|
|
|
if (outputNode.inputs && outputNode.inputs.text !== currentValue) {
|
|
outputNode.inputs.text = currentValue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 === "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;
|
|
setTimeout(() => {
|
|
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
|
|
textWidget.inputEl.addEventListener('input', syncValue);
|
|
// Sync on change (for paste, autocomplete, etc.)
|
|
textWidget.inputEl.addEventListener('change', syncValue);
|
|
// Sync when focus leaves the field
|
|
textWidget.inputEl.addEventListener('blur', syncValue);
|
|
// Sync on keyup as backup
|
|
textWidget.inputEl.addEventListener('keyup', syncValue);
|
|
|
|
// 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: #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 =
|
|
'<div style="color: #999; text-align: center; padding: 20px;">💡 Click Search or Recent to find prompts</div>';
|
|
|
|
this.resultsSection = resultsSection;
|
|
searchSection.appendChild(resultsSection);
|
|
|
|
// Add search section to wrapper, then wrapper to container
|
|
wrapper.appendChild(searchSection);
|
|
container.appendChild(wrapper);
|
|
};
|
|
|
|
// Method to perform search
|
|
nodeType.prototype.performSearch = async function () {
|
|
try {
|
|
this.resultsSection.innerHTML =
|
|
'<div style="color: #999; text-align: center;">🔍 Searching database...</div>';
|
|
|
|
// search_text supports free text plus tag:name filters
|
|
const { text, tags } = parseSearchQuery(
|
|
this.widgets?.find((w) => w.name === "search_text")?.value || "",
|
|
);
|
|
|
|
// Validate search criteria
|
|
if (!text && tags.length === 0) {
|
|
this.resultsSection.innerHTML =
|
|
'<div style="color: #f9a825; text-align: center;">⚠️ Enter search text or tag:name</div>';
|
|
return;
|
|
}
|
|
|
|
// Call the backend search function
|
|
const response = await this.callNodeMethod("search_prompts", {
|
|
search_text: text,
|
|
tags,
|
|
});
|
|
|
|
this.displayResults(response.results || []);
|
|
|
|
// Show success notification
|
|
if (response.results && response.results.length > 0) {
|
|
this.showNotification(
|
|
`Found ${response.results.length} prompts`,
|
|
"success",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("[PromptManager] Search error:", error);
|
|
this.resultsSection.innerHTML = `<div style="color: #f88; text-align: center;">❌ Search error: ${escapeHtml(error.message)}</div>`;
|
|
this.showNotification(`Search failed: ${error.message}`, "error");
|
|
}
|
|
};
|
|
|
|
// Method to load recent prompts
|
|
nodeType.prototype.loadRecentPrompts = async function () {
|
|
try {
|
|
this.resultsSection.innerHTML =
|
|
'<div style="color: #999; text-align: center;">📋 Loading recent prompts...</div>';
|
|
|
|
const response = await this.callNodeMethod("get_recent_prompts", {
|
|
limit: 20,
|
|
});
|
|
this.displayResults(response.results || []);
|
|
|
|
// Show success notification
|
|
if (response.results && response.results.length > 0) {
|
|
this.showNotification(
|
|
`Loaded ${response.results.length} recent prompts`,
|
|
"success",
|
|
);
|
|
} else {
|
|
this.showNotification("No recent prompts found", "info");
|
|
}
|
|
} catch (error) {
|
|
console.error("[PromptManager] Recent prompts error:", error);
|
|
this.resultsSection.innerHTML = `<div style="color: #f88; text-align: center;">❌ Error loading recent prompts: ${escapeHtml(error.message)}</div>`;
|
|
this.showNotification(
|
|
`Failed to load recent prompts: ${error.message}`,
|
|
"error",
|
|
);
|
|
}
|
|
};
|
|
|
|
// Method to load settings from API
|
|
nodeType.prototype.loadSettings = async function () {
|
|
try {
|
|
const response = await fetch("/prompt_manager/settings");
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.settings) {
|
|
this.properties.resultTimeout = data.settings.result_timeout ?? 3;
|
|
this.properties.showTestButton =
|
|
data.settings.show_test_button || false;
|
|
this.properties.webuiDisplayMode =
|
|
data.settings.webui_display_mode || "popup";
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Settings not available, use defaults
|
|
}
|
|
};
|
|
|
|
// Method to start auto-hide timer
|
|
nodeType.prototype.startAutoHideTimer = function () {
|
|
// Clear existing timer
|
|
if (this.resultHideTimer) {
|
|
clearTimeout(this.resultHideTimer);
|
|
}
|
|
|
|
// Only set timer if timeout > 0
|
|
if (this.properties.resultTimeout > 0) {
|
|
this.resultHideTimer = setTimeout(() => {
|
|
this.hideResults();
|
|
}, this.properties.resultTimeout * 1000);
|
|
}
|
|
};
|
|
|
|
// Method to hide results
|
|
nodeType.prototype.hideResults = function () {
|
|
if (this.resultsSection) {
|
|
// Completely hide the results section
|
|
this.resultsSection.style.display = "none";
|
|
|
|
// Create a restore button in place of the results
|
|
const restoreButton = document.createElement("div");
|
|
restoreButton.style.padding = "20px";
|
|
restoreButton.style.backgroundColor = "#333";
|
|
restoreButton.style.border = "1px solid #444";
|
|
restoreButton.style.borderRadius = "4px";
|
|
restoreButton.style.textAlign = "center";
|
|
restoreButton.style.cursor = "pointer";
|
|
restoreButton.style.color = "#999";
|
|
restoreButton.style.fontSize = "12px";
|
|
restoreButton.innerHTML =
|
|
"🔄 Results auto-hidden<br><small>Click to restore</small>";
|
|
|
|
// Add hover effect
|
|
restoreButton.addEventListener("mouseenter", () => {
|
|
restoreButton.style.backgroundColor = "#444";
|
|
restoreButton.style.color = "#ccc";
|
|
});
|
|
restoreButton.addEventListener("mouseleave", () => {
|
|
restoreButton.style.backgroundColor = "#333";
|
|
restoreButton.style.color = "#999";
|
|
});
|
|
|
|
// Click to restore
|
|
restoreButton.addEventListener("click", () => {
|
|
this.showResults();
|
|
});
|
|
|
|
// Store reference and insert after results section
|
|
this.restoreButton = restoreButton;
|
|
this.resultsSection.parentNode.insertBefore(
|
|
restoreButton,
|
|
this.resultsSection.nextSibling,
|
|
);
|
|
}
|
|
};
|
|
|
|
// Method to show results
|
|
nodeType.prototype.showResults = function () {
|
|
if (this.resultsSection) {
|
|
// Show the results section
|
|
this.resultsSection.style.display = "block";
|
|
|
|
// Remove restore button if it exists
|
|
if (this.restoreButton && this.restoreButton.parentNode) {
|
|
this.restoreButton.parentNode.removeChild(this.restoreButton);
|
|
this.restoreButton = null;
|
|
}
|
|
|
|
// Restart timer
|
|
this.startAutoHideTimer();
|
|
}
|
|
};
|
|
|
|
// Method to display search results
|
|
nodeType.prototype.displayResults = function (results) {
|
|
if (!results || results.length === 0) {
|
|
this.resultsSection.innerHTML =
|
|
'<div style="color: #f9a825; text-align: center; padding: 20px;">📭 No prompts found</div>';
|
|
return;
|
|
}
|
|
|
|
this._searchResults = results;
|
|
this.resultsSection.innerHTML = "";
|
|
|
|
// Ensure results are visible
|
|
this.resultsSection.style.opacity = "1";
|
|
this.resultsSection.style.pointerEvents = "auto";
|
|
|
|
// Header
|
|
const header = document.createElement("div");
|
|
header.style.marginBottom = "10px";
|
|
header.style.fontWeight = "bold";
|
|
header.style.color = "#4facfe";
|
|
header.style.textAlign = "center";
|
|
header.style.padding = "8px";
|
|
header.style.backgroundColor = "rgba(79, 172, 254, 0.1)";
|
|
header.style.borderRadius = "4px";
|
|
header.innerHTML = `📚 Found ${results.length} prompt${results.length === 1 ? "" : "s"}`;
|
|
this.resultsSection.appendChild(header);
|
|
|
|
// Start auto-hide timer
|
|
this.startAutoHideTimer();
|
|
|
|
// Results list
|
|
results.forEach((result, index) => {
|
|
const resultItem = document.createElement("div");
|
|
resultItem.style.padding = "6px";
|
|
resultItem.style.marginBottom = "4px";
|
|
resultItem.style.backgroundColor = "#444";
|
|
resultItem.style.borderRadius = "3px";
|
|
resultItem.style.cursor = "pointer";
|
|
resultItem.style.border = "1px solid transparent";
|
|
|
|
// Hover effect
|
|
resultItem.addEventListener("mouseenter", () => {
|
|
resultItem.style.backgroundColor = "#555";
|
|
resultItem.style.border = "1px solid #666";
|
|
});
|
|
resultItem.addEventListener("mouseleave", () => {
|
|
resultItem.style.backgroundColor = "#444";
|
|
resultItem.style.border = "1px solid transparent";
|
|
});
|
|
|
|
// Click to use prompt
|
|
resultItem.addEventListener("click", () => {
|
|
this.usePrompt(result, index);
|
|
});
|
|
|
|
// Format display
|
|
const tags = Array.isArray(result.tags)
|
|
? result.tags.join(", ")
|
|
: "No tags";
|
|
const rating = result.rating ? `⭐${result.rating}` : "No rating";
|
|
const category = result.category || "No category";
|
|
const created = result.created_at
|
|
? new Date(result.created_at).toLocaleDateString()
|
|
: "Unknown";
|
|
|
|
const promptPreview =
|
|
result.text.length > 80
|
|
? result.text.substring(0, 80) + "..."
|
|
: result.text;
|
|
|
|
// All user-controlled values are escaped to prevent XSS
|
|
resultItem.innerHTML = `
|
|
<div style="color: #e8e8e8; font-weight: bold; margin-bottom: 4px; line-height: 1.3;">
|
|
${escapeHtml(promptPreview)}
|
|
</div>
|
|
<div style="color: #aaa; font-size: 10px; line-height: 1.4;">
|
|
<div style="margin-bottom: 2px;">
|
|
<span style="color: #4facfe;">📁 ${escapeHtml(category)}</span> •
|
|
<span style="color: #ffd700;">${escapeHtml(rating)}</span> •
|
|
<span style="color: #8cc8ff;">📅 ${escapeHtml(created)}</span>
|
|
</div>
|
|
<div style="color: #9c9c9c;">
|
|
🏷️ ${escapeHtml(tags)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.resultsSection.appendChild(resultItem);
|
|
});
|
|
};
|
|
|
|
// Method to use a selected prompt
|
|
nodeType.prototype.usePrompt = function (prompt, index) {
|
|
// Find the text widget and set its value
|
|
const textWidget = this.widgets?.find((w) => w.name === "text");
|
|
if (textWidget) {
|
|
const textWidgetIndex = this.widgets.indexOf(textWidget);
|
|
|
|
// Set the widget value
|
|
textWidget.value = prompt.text;
|
|
|
|
// CRITICAL: For multiline widgets, the value is stored in the DOM element (inputEl)
|
|
// graphToPrompt calls serializeValue() which reads from inputEl.value
|
|
if (textWidget.inputEl) {
|
|
textWidget.inputEl.value = prompt.text;
|
|
}
|
|
|
|
// CRITICAL: Also update widgets_values array if it exists
|
|
// ComfyUI's graphToPrompt reads from this array for execution
|
|
if (this.widgets_values && textWidgetIndex >= 0) {
|
|
this.widgets_values[textWidgetIndex] = prompt.text;
|
|
}
|
|
|
|
// Trigger widget callback to notify ComfyUI of value change
|
|
if (textWidget.callback) {
|
|
textWidget.callback(prompt.text, app.canvas, this, null, null);
|
|
}
|
|
|
|
// Also set metadata if available
|
|
const categoryWidget = this.widgets?.find(
|
|
(w) => w.name === "category",
|
|
);
|
|
if (categoryWidget && prompt.category) {
|
|
const categoryIdx = this.widgets.indexOf(categoryWidget);
|
|
categoryWidget.value = prompt.category;
|
|
if (categoryWidget.inputEl) {
|
|
categoryWidget.inputEl.value = prompt.category;
|
|
}
|
|
if (this.widgets_values && categoryIdx >= 0) {
|
|
this.widgets_values[categoryIdx] = prompt.category;
|
|
}
|
|
if (categoryWidget.callback) {
|
|
categoryWidget.callback(prompt.category, app.canvas, this, null, null);
|
|
}
|
|
}
|
|
|
|
const tagsWidget = this.widgets?.find((w) => w.name === "tags");
|
|
if (tagsWidget && prompt.tags) {
|
|
const tagsIdx = this.widgets.indexOf(tagsWidget);
|
|
const tagsValue = Array.isArray(prompt.tags)
|
|
? prompt.tags.join(", ")
|
|
: prompt.tags;
|
|
tagsWidget.value = tagsValue;
|
|
if (tagsWidget.inputEl) {
|
|
tagsWidget.inputEl.value = tagsValue;
|
|
}
|
|
if (this.widgets_values && tagsIdx >= 0) {
|
|
this.widgets_values[tagsIdx] = tagsValue;
|
|
}
|
|
if (tagsWidget.callback) {
|
|
tagsWidget.callback(tagsValue, app.canvas, this, null, null);
|
|
}
|
|
}
|
|
|
|
// Visual feedback
|
|
this.highlightSelectedResult(index);
|
|
|
|
// Trigger widget change events and mark graph as needing re-execution
|
|
this.setDirtyCanvas(true, true);
|
|
|
|
// Force graph to recognize value change for execution cache invalidation
|
|
if (app.graph) {
|
|
app.graph.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
}
|
|
};
|
|
|
|
// Method to highlight selected result
|
|
nodeType.prototype.highlightSelectedResult = function (index) {
|
|
const resultItems = this.resultsSection.querySelectorAll(
|
|
"div[style*='cursor: pointer']",
|
|
);
|
|
resultItems.forEach((item, i) => {
|
|
if (i === index) {
|
|
item.style.backgroundColor = "#2a5d31";
|
|
item.style.border = "1px solid #4a8c57";
|
|
} else {
|
|
item.style.backgroundColor = "#444";
|
|
item.style.border = "1px solid transparent";
|
|
}
|
|
});
|
|
};
|
|
|
|
// Method to call backend API endpoints
|
|
nodeType.prototype.callNodeMethod = async function (method, params) {
|
|
try {
|
|
let url,
|
|
options = { method: "GET" };
|
|
|
|
if (method === "search_prompts") {
|
|
const queryParams = new URLSearchParams();
|
|
if (params.search_text)
|
|
queryParams.append("text", params.search_text);
|
|
if (params.tags?.length)
|
|
queryParams.append("tags", params.tags.join(","));
|
|
queryParams.append("limit", "50");
|
|
|
|
url = `/prompt_manager/search?${queryParams.toString()}`;
|
|
} else if (method === "get_recent_prompts") {
|
|
const limit = params.limit || 20;
|
|
url = `/prompt_manager/recent?limit=${limit}`;
|
|
} else {
|
|
throw new Error(`Unknown method: ${method}`);
|
|
}
|
|
|
|
const response = await fetch(url, options);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
throw new Error(data.error || "API call failed");
|
|
}
|
|
|
|
return { results: data.results || [] };
|
|
} catch (error) {
|
|
console.error(
|
|
`[PromptManager] API call failed for ${method}:`,
|
|
error,
|
|
);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Method to save a prompt via API
|
|
nodeType.prototype.savePromptToDatabase = async function (promptData) {
|
|
try {
|
|
const response = await fetch("/prompt_manager/save", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(promptData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
throw new Error(data.error || "Save failed");
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error("[PromptManager] Save prompt failed:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Method to delete a prompt via API
|
|
nodeType.prototype.deletePrompt = async function (promptId) {
|
|
try {
|
|
const response = await fetch(`/prompt_manager/delete/${promptId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
throw new Error(data.error || "Delete failed");
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error("[PromptManager] Delete prompt failed:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Method to test API connection
|
|
nodeType.prototype.testApiConnection = async function () {
|
|
try {
|
|
this.resultsSection.innerHTML =
|
|
'<div style="color: #999; text-align: center;">🔧 Testing API connection...</div>';
|
|
|
|
const response = await fetch("/prompt_manager/test");
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.resultsSection.innerHTML = `
|
|
<div style="color: #4caf50; text-align: center; padding: 10px;">
|
|
✅ API Connection Successful!<br>
|
|
<small>${data.message}</small><br>
|
|
<small>Time: ${data.timestamp}</small>
|
|
</div>
|
|
`;
|
|
this.showNotification("API connection test passed!", "success");
|
|
} else {
|
|
throw new Error(data.message || "Test failed");
|
|
}
|
|
} catch (error) {
|
|
console.error("[PromptManager] API test failed:", error);
|
|
this.resultsSection.innerHTML = `
|
|
<div style="color: #f44336; text-align: center; padding: 10px;">
|
|
❌ API Connection Failed<br>
|
|
<small>${error.message}</small>
|
|
</div>
|
|
`;
|
|
this.showNotification(`API test failed: ${error.message}`, "error");
|
|
}
|
|
};
|
|
|
|
// Method to open web interface
|
|
nodeType.prototype.openWebInterface = function () {
|
|
try {
|
|
// Open the web interface served via our API endpoint
|
|
const currentOrigin = window.location.origin;
|
|
const webUrl = `${currentOrigin}/prompt_manager/web`;
|
|
|
|
if (this.properties.webuiDisplayMode === "newtab") {
|
|
// Open in new tab without window options
|
|
const newWindow = window.open(webUrl, "_blank");
|
|
|
|
if (newWindow) {
|
|
this.showNotification(
|
|
"Web interface opened in new tab",
|
|
"success",
|
|
);
|
|
} else {
|
|
window.location.href = webUrl;
|
|
}
|
|
} else {
|
|
// Default popup mode with specific dimensions
|
|
const newWindow = window.open(
|
|
webUrl,
|
|
"_blank",
|
|
"width=1200,height=800,scrollbars=yes,resizable=yes",
|
|
);
|
|
|
|
if (newWindow) {
|
|
this.showNotification("Web interface opened in popup", "success");
|
|
} else {
|
|
window.location.href = webUrl;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("[PromptManager] Error opening web interface:", error);
|
|
this.showNotification(
|
|
`Web interface error: ${error.message}`,
|
|
"error",
|
|
);
|
|
}
|
|
};
|
|
|
|
// Method to show notifications
|
|
nodeType.prototype.showNotification = function (message, type = "info") {
|
|
// Create a temporary notification element
|
|
const notification = document.createElement("div");
|
|
notification.style.position = "fixed";
|
|
notification.style.top = "20px";
|
|
notification.style.right = "20px";
|
|
notification.style.padding = "10px 15px";
|
|
notification.style.borderRadius = "4px";
|
|
notification.style.color = "white";
|
|
notification.style.fontSize = "12px";
|
|
notification.style.zIndex = "10000";
|
|
notification.style.maxWidth = "300px";
|
|
notification.textContent = message;
|
|
|
|
// Set color based on type
|
|
switch (type) {
|
|
case "success":
|
|
notification.style.backgroundColor = "#4caf50";
|
|
break;
|
|
case "error":
|
|
notification.style.backgroundColor = "#f44336";
|
|
break;
|
|
case "warning":
|
|
notification.style.backgroundColor = "#ff9800";
|
|
break;
|
|
default:
|
|
notification.style.backgroundColor = "#2196f3";
|
|
}
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
// Remove after 3 seconds
|
|
setTimeout(() => {
|
|
document.body.removeChild(notification);
|
|
}, 3000);
|
|
};
|
|
}
|
|
},
|
|
});
|