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)
1148 lines
41 KiB
JavaScript
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);
|
|
};
|
|
}
|
|
},
|
|
});
|