Files
ComfyAssets-ComfyUI_PromptM…/web/comfy/prompt_manager.js
T
Vito cd284432f5 feat(cutover): every entry point opens the workspace; legacy pages stay reachable (#528)
* feat(cutover): every entry point opens the workspace; legacy pages stay reachable

The top-bar PM button, the nodes' Web UI buttons and /prompt_manager/web now
open the workspace Library. Overview's cards open their native pages. The legacy
pages stay at their URLs, with no route changed.

Classic Web UI no longer navigates the canvas away when a popup is blocked; it
shows a notification, as the focused node does.

Closes #525

* feat(cutover): link the legacy pages from System and when the workspace fails to load

System's Overview section ends with one small Legacy pages line (D165). If a
workspace module fails to load or throws while starting, the page shows a static
message with the same links instead of an empty shell.

Part of #525

* test(cutover): prove the Pair node's Web UI on the real host

Part of #525

* test(cutover): count the new real-host cases in the stock matrix pin

The PM button test and three renderer-and-reload tests, in 2 projects, take the
stock matrix from 110 to 118.

Part of #525
2026-10-30 22:06:12 +00:00

1268 lines
47 KiB
JavaScript

// PromptManager/web/prompt_manager.js
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js'));
const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js'));
const { normalizeSaveWidget } = await import(api.fileURL('/prompt_manager/js/save-to-library.js'));
const { WORKSPACE_ENTRY_ROUTE } = await import(api.fileURL('/prompt_manager/js/workspace-entry.js'));
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
// ============================================================================
// Top Bar Button Integration (similar to rgthree-comfy approach)
// ============================================================================
let pmButtonGroup = null;
/**
* Escape HTML special characters to prevent XSS in innerHTML contexts.
*/
function escapeHtml(text) {
if (text == null) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Split node search input into free text and tag filters (#150).
* `tag:portrait tag:"film grain" sunset` -> { text: "sunset", tags: ["portrait", "film grain"] }
* @param {string} input - Raw search_text widget value
* @returns {{text: string, tags: string[]}}
*/
function parseSearchQuery(input) {
const tags = [];
const text = (input || "")
.replace(/\btag:(?:"([^"]+)"|(\S+))/gi, (_, quoted, bare) => {
tags.push((quoted || bare).trim());
return " ";
})
.replace(/\s+/g, " ")
.trim();
return { text, tags: tags.filter(Boolean) };
}
/**
* Create a DOM element with optional attributes and children
* @param {string} tag - HTML tag name
* @param {Object} attrs - Attributes and properties
* @returns {HTMLElement}
*/
function $el(tag, attrs = {}) {
const element = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === "children") {
if (Array.isArray(value)) {
value.forEach(child => child && element.appendChild(child));
} else if (value) {
element.appendChild(value);
}
} else if (key === "child") {
if (value) element.appendChild(value);
} else if (key === "text") {
element.textContent = value;
} else if (key === "html") {
element.innerHTML = value;
} else if (key === "style" && typeof value === "object") {
Object.assign(element.style, value);
} else if (key === "classList") {
element.className = value;
} else if (key.startsWith("on") && typeof value === "function") {
element.addEventListener(key.slice(2).toLowerCase(), value);
} else {
element.setAttribute(key, value);
}
}
return element;
}
/**
* Add PromptManager button to the top bar
*/
function addPMTopBarButton() {
// Remove existing button group if present
if (pmButtonGroup?.parentElement) {
pmButtonGroup.parentElement.removeChild(pmButtonGroup);
pmButtonGroup = null;
}
// Check if app.menu exists (new frontend)
if (!app.menu?.settingsGroup?.element) {
// New frontend menu not found, try legacy approach
addPMTopBarButtonLegacy();
return;
}
// Create button group container
pmButtonGroup = $el("div", {
classList: "comfyui-button-group promptmanager-top-button-group",
style: {
display: "flex",
gap: "4px",
marginRight: "8px",
}
});
// Create PM button
const pmButton = $el("button", {
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
title: "PromptManager - Open workspace",
style: {
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4px 10px",
background: "#14b8a6",
color: "white",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
fontSize: "12px",
cursor: "pointer",
transition: "background 0.2s",
},
onclick: () => {
window.open(api.fileURL(WORKSPACE_ENTRY_ROUTE), "_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 workspace";
// Teal button styling
btn.style.cssText = `
margin-left: 8px;
padding: 4px 12px;
background: #14b8a6;
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
`;
btn.onmouseenter = () => btn.style.background = "#0d9488";
btn.onmouseleave = () => btn.style.background = "#14b8a6";
btn.onclick = () => {
window.open(api.fileURL(WORKSPACE_ENTRY_ROUTE), "_blank");
};
topBar.appendChild(btn);
}
// ============================================================================
// Extension Registration
// ============================================================================
/**
* CRITICAL FIX: Sync all PromptManager widget values from DOM
* This must be called before any serialization happens
*/
function syncPromptManagerWidgets() {
const graph = app.graph;
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if (promptManagerTypes.has(node.type)) {
if (node.widgets && node.widgets.length > 0) {
if (!node.widgets_values) {
node.widgets_values = [];
}
node.widgets.forEach((widget, idx) => {
if (widget) {
// A linked widget keeps the text the user typed (#343): the link drives the run,
// the workflow keeps the typed text for when the link is disconnected.
// For non-connected widgets, inputEl.value is the true source
const actualValue = storedWidgetValue(node, widget);
if (actualValue !== undefined) {
node.widgets_values[idx] = actualValue;
// Also sync widget.value
if (widget.inputEl && widget.value !== actualValue) {
widget.value = actualValue;
}
}
}
});
}
}
}
}
/**
* #343: a legacy frontend Primitive linked to a PromptManager editor text input runs with the Primitive's value.
* The Primitive writes into the target's widget, but the editor text inputs are no longer bound to their hidden
* widgets, so the queued value would otherwise be the hidden typed text while the editor says "linked". Inputs still
* bound to a widget (the Classic nodes) are left as the host resolved them.
*
* A wildcard (`{red|blue}`) draws a new choice each time it is resolved, so each queued output is filled once: by the
* graphToPrompt wrapper, or by the queue hook when a caller queues an output that did not come through it.
*/
const primitiveTextApplied = new WeakSet();
const MAX_SUBGRAPH_DEPTH = 32;
/** Every node with the id it is queued under: a node inside a subgraph is `<subgraph node id>:<inner id>`. */
function* queuedNodes(graph, prefix = '', depth = 0) {
for (const node of graph?._nodes || graph?.nodes || []) {
yield [`${prefix}${node.id}`, node];
if (depth < MAX_SUBGRAPH_DEPTH && node.isSubgraphNode?.() && node.subgraph) {
yield* queuedNodes(node.subgraph, `${prefix}${node.id}:`, depth + 1);
}
}
}
async function applyPrimitiveText(output) {
if (!output || typeof output !== 'object' || primitiveTextApplied.has(output)) return;
primitiveTextApplied.add(output);
const replaceText = window.comfyAPI?.utils?.applyTextReplacements;
for (const [id, node] of queuedNodes(app.graph)) {
const queued = promptManagerTypes.has(node.type) ? output[id] : null;
// Only an output of this graph: the same id must hold the same node type.
const inputs = queued?.class_type === node.type ? queued.inputs : null;
if (!inputs) continue;
for (const name of ['text', 'negative_text']) {
if (Array.isArray(inputs[name])) continue;
const value = await primitiveTextValue(node, name, { replaceText });
if (value !== undefined) inputs[name] = value;
}
}
}
app.registerExtension({
name: "PromptManager.UI",
async setup() {
installPromptBridge(app, { baseUrl: api.fileURL('/prompt_manager/') });
// Add the top bar button after a short delay to ensure UI is ready
setTimeout(() => {
addPMTopBarButton();
}, 100);
// CRITICAL: Wrap app.graphToPrompt to sync widget values BEFORE serialization
// This runs before any other extension's wrapper can access stale data
const originalGraphToPrompt = app.graphToPrompt.bind(app);
app.graphToPrompt = async function(...args) {
syncPromptManagerWidgets();
const result = await originalGraphToPrompt(...args);
await applyPrimitiveText(result?.output);
return result;
};
// CRITICAL FIX: Intercept api.queuePrompt to fix prompt data at the LAST moment
// This catches any cached/stale data that other extensions might have passed through
// NOTE: The third parameter (...rest) carries partialExecutionTargets and previewMethod
// which ComfyUI uses for branch execution. Dropping it breaks partial execution.
const originalQueuePrompt = api.queuePrompt.bind(api);
api.queuePrompt = async function(number, { output, workflow }, ...rest) {
// Find PromptManager nodes in the output and fix their text values
const graph = app.graph;
if (graph && output) {
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if (promptManagerTypes.has(node.type) && output[node.id]) {
const outputNode = output[node.id];
for (const name of node.type === 'PromptManagerPair' ? ['text','negative_text'] : ['text']) {
if (!outputNode.inputs || Array.isArray(outputNode.inputs[name]) || node.inputs?.some(input => input.name === name && input.link != null)) continue;
const widget = node.widgets?.find(widget => widget.name === name);
if (widget?.inputEl) outputNode.inputs[name] = widget.inputEl.value;
}
}
}
}
await applyPrimitiveText(output);
return originalQueuePrompt(number, { output, workflow }, ...rest);
};
},
/**
* CRITICAL: Also hook beforeQueuePrompt as a backup sync point
*/
async beforeQueuePrompt(promptInfo) {
syncPromptManagerWidgets();
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'PromptManagerFocused' || nodeData.name === 'PromptManagerSplit' || nodeData.name === 'PromptManagerPair') {
const install = nodeData.name === 'PromptManagerPair' ? installPairNode : nodeData.name === 'PromptManagerSplit' ? installSplitNode : installFocusedNode;
install(nodeType, app, api, parseSearchQuery);
return;
}
if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") {
// Store original methods
const onNodeCreated = nodeType.prototype.onNodeCreated;
// Override onNodeCreated to add custom UI
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// CRITICAL FIX: Ensure text widget always returns current value during serialization
// This fixes the issue where graphToPrompt reads stale cached values
const self = this;
// Settles once the serializer below is in place (or skipped), for callers that must queue after it.
let textSerializerSettled;
this._pmTextSerializerReady = new Promise((resolve) => { textSerializerSettled = resolve; });
this._textSyncTimer = setTimeout(() => {
try {
if (self._classicScope?.signal.aborted) return;
const textWidget = self.widgets?.find((w) => w.name === "text");
if (textWidget) {
// Store reference to the widget for the closure
const widgetRef = textWidget;
const originalSerializeValue = textWidget.serializeValue;
// Override serializeValue - use arrow function to capture widgetRef
textWidget.serializeValue = (nodeId, widgetIndex) => {
// Always return the current DOM value using our stored reference
if (widgetRef.inputEl) {
const currentValue = widgetRef.inputEl.value;
// Sync widget.value as well
widgetRef.value = currentValue;
return currentValue;
}
return widgetRef.value;
};
// Add aggressive sync listeners for all value change events
if (textWidget.inputEl) {
const syncValue = function() {
const currentVal = textWidget.inputEl.value;
textWidget.value = currentVal;
// Also update widgets_values array
const widgetIndex = self.widgets?.indexOf(textWidget);
if (self.widgets_values && widgetIndex >= 0) {
self.widgets_values[widgetIndex] = currentVal;
}
};
// Sync on every keystroke
const listen = self._classicScope
? (event) => self._classicScope.listen(textWidget.inputEl, event, syncValue)
: (event) => textWidget.inputEl.addEventListener(event, syncValue);
listen('input');
// Sync on change (for paste, autocomplete, etc.)
listen('change');
// Sync when focus leaves the field
listen('blur');
// Sync on keyup as backup
listen('keyup');
// Initial sync
syncValue();
}
}
} finally {
textSerializerSettled();
}
}, 50); // Shorter delay to beat other extensions
// Initialize properties for search state (non-serialized runtime state)
this.properties = this.properties || {};
this.properties.resultTimeout = 3; // Default 3 seconds
this.properties.showTestButton = false; // Default hide test button
this.properties.webuiDisplayMode = "popup"; // Default popup mode
// Runtime-only state (not serialized to workflow)
this._searchResults = [];
this._selectedPromptIndex = -1;
this.resultHideTimer = null;
// Store node type for conditional behavior
this.nodeTypeName = nodeData.name;
// Load settings from API
this.loadSettings();
// Create DOM widget container with unique ID for scoped styling
const container = document.createElement("div");
const uniqueId = `pm-${Math.random().toString(36).substring(2, 9)}`;
container.id = uniqueId;
// Add inline styles for perfect sizing like KikoLocalImageLoader
const styleSheet = document.createElement("style");
styleSheet.textContent = `
#${uniqueId} {
width: 100%;
height: 100%;
padding: 8px;
background-color: ${nodeData.name === 'PromptManager' ? 'var(--ca-surface)' : '#2a2a2a'};
border-radius: 4px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
#${uniqueId} > div {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
`;
document.head.appendChild(styleSheet);
// Store style reference for cleanup
this._styleSheet = styleSheet;
this.createSearchUI(container);
// Add as DOM widget with proper configuration
this.searchWidget = this.addDOMWidget(
"prompt_manager_search_ui",
"div",
container,
{
serialize: false, // Don't save UI state
}
);
// Set initial node size, but preserve user resizes
if (!this._userHasResized) {
// For nodes with text widgets, we need more height
const baseHeight = 400;
const widgetHeight = this.widgets ? this.widgets.length * 30 : 0;
this.size = [600, baseHeight + widgetHeight]; // Dynamic size based on widgets
this.setSize(this.size); // IMPORTANT: Actually apply the size
}
// Hook into resize to track user changes
const originalOnResize = this.onResize;
this.onResize = function (size) {
this._userHasResized = true;
if (originalOnResize) {
originalOnResize.call(this, size);
}
};
// Hook into serialization to preserve resize flag, sync widget values, and prevent runtime data from being saved
const originalSerialize = this.serialize;
this.serialize = function () {
// Sync widget values BEFORE serialization, but only for non-connected widgets
if (this.widgets && this.widgets.length > 0) {
if (!this.widgets_values) {
this.widgets_values = [];
}
this.widgets.forEach((widget, idx) => {
if (widget) {
// A linked widget stores its typed text, not the live editor (#343)
const actualValue = storedWidgetValue(self, widget);
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);
}
// #90: an older workflow loads with Save to library on
normalizeSaveWidget(this);
if (data._userHasResized) {
this._userHasResized = data._userHasResized;
}
this.renderTextLink();
};
this.setDirtyCanvas(true, true);
};
// #343: text can come from a link like any core widget. The host hides the text box;
// the controls say why, and the typed text is kept for when the link is disconnected.
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (...args) {
const result = onConnectionsChange?.apply(this, args);
this.renderTextLink();
return result;
};
nodeType.prototype.createTextLinkNote = function () {
const note = document.createElement("p");
note.className = "pm-text-link-note";
note.setAttribute("role", "status");
note.textContent = linkNoteText("Text");
this._textLinkNote = note;
this.renderTextLink();
return note;
};
nodeType.prototype.renderTextLink = function () {
const linked = isLinked(this, "text");
if (this._textLinkNote) this._textLinkNote.hidden = !linked;
collapseLinkedWidget(this.widgets?.find((w) => w.name === "text"), linked);
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";
const linkNote = this.createTextLinkNote();
linkNote.style.cssText = "margin: 0; color: var(--fg-color); font-size: 12px;";
searchSection.appendChild(linkNote);
// 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(api.fileURL("/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) {
// The typed text under a link is kept for when it is disconnected (#343)
if (isLinked(this, "text")) {
this.showNotification("Text comes from a link. Disconnect it to use a saved prompt.", "error");
return;
}
// 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}&sort=last_used_desc`;
} else {
throw new Error(`Unknown method: ${method}`);
}
if (this._classicScope) options.signal = this._classicScope.signal;
const response = await fetch(api.fileURL(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) {
if (!this._classicScope?.signal.aborted) {
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(api.fileURL("/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(api.fileURL(`/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(api.fileURL("/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 {
const webUrl = api.fileURL(WORKSPACE_ENTRY_ROUTE);
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 {
// Never navigate the canvas away: it may hold unsaved work.
this.showNotification("Allow popups to open Web UI, then try again.", "error");
}
} 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 {
// Never navigate the canvas away: it may hold unsaved work.
this.showNotification("Allow popups to open Web UI, then try again.", "error");
}
}
} 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);
};
if (nodeData.name === 'PromptManager') installClassicControls(nodeType, app, parseSearchQuery, api);
}
},
});