* 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
1268 lines
47 KiB
JavaScript
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);
|
|
}
|
|
},
|
|
});
|