Files
ComfyAssets-ComfyUI_PromptM…/web/prompt_manager.js
T
Vito Sansevero 4548beb723 refactor: split api.py into domain modules and extract frontend JS
Phase 4 structural refactoring:
- Split monolithic py/api.py (5.3k lines) into domain mixins:
  prompts.py, images.py, admin.py, logging_routes.py, autotag_routes.py
- Extract inline JS from admin.html into web/js/admin.js
- Extract inline JS from gallery.html into web/js/gallery.js
- Add gzip compression middleware to API responses (Phase 5.4)
- Standardize API error envelope with success: false (Phase 5.3)
- Add filmstrip image enrichment to prompt list responses (Phase 5.2)
2026-02-07 07:02:25 -08:00

1148 lines
41 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;
}
/**
* 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) {
// CRITICAL: For multiline widgets, inputEl.value is the true source
const actualValue = widget.inputEl ? widget.inputEl.value : widget.value;
if (actualValue !== undefined) {
const oldValue = node.widgets_values[idx];
node.widgets_values[idx] = actualValue;
// Also sync widget.value
if (widget.inputEl && widget.value !== actualValue) {
widget.value = actualValue;
}
// text widget value synced from DOM
}
}
});
}
}
}
}
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]) {
// Find the text widget and get its current DOM value
const textWidget = node.widgets?.find(w => w.name === "text");
if (textWidget && textWidget.inputEl) {
const currentValue = textWidget.inputEl.value;
const outputNode = output[node.id];
// The output structure has "inputs" with widget values
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 () {
// CRITICAL: Sync widget values BEFORE serialization
// This ensures graphToPrompt reads current values, not cached ones
// For multiline widgets, the value is stored in inputEl.value
if (this.widgets && this.widgets.length > 0) {
if (!this.widgets_values) {
this.widgets_values = [];
}
this.widgets.forEach((widget, idx) => {
if (widget) {
// Get the actual value - inputEl.value for DOM widgets, otherwise widget.value
const actualValue = widget.inputEl ? widget.inputEl.value : widget.value;
if (actualValue !== undefined) {
this.widgets_values[idx] = actualValue;
// Sync widget.value with inputEl.value
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>';
// Get search criteria from node widgets (only search_text available now)
const searchText =
this.widgets?.find((w) => w.name === "search_text")?.value || "";
// Validate search criteria
if (!searchText.trim()) {
this.resultsSection.innerHTML =
'<div style="color: #f9a825; text-align: center;">⚠️ Please enter search text</div>';
return;
}
// Call the backend search function
const response = await this.callNodeMethod("search_prompts", {
search_text: searchText,
});
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") {
// Build query string for search (simplified - only text search)
const queryParams = new URLSearchParams();
if (params.search_text)
queryParams.append("text", params.search_text);
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);
};
}
},
});