feat: add Prompt Library node

Managed multiline text box backed by a shared JSON library: save/update/
delete named prompts (with description/tags) and reload them via a
searchable dropdown picker on the node.
This commit is contained in:
moonwhaler
2026-08-11 12:39:20 +02:00
parent 91a45924aa
commit 4aab4830e0
6 changed files with 799 additions and 0 deletions
+7
View File
@@ -44,7 +44,12 @@ from .text_builder_node import (
NODE_CLASS_MAPPINGS as tb_class_mappings,
NODE_DISPLAY_NAME_MAPPINGS as tb_display_mappings,
)
from .prompt_library_node import (
NODE_CLASS_MAPPINGS as pl_class_mappings,
NODE_DISPLAY_NAME_MAPPINGS as pl_display_mappings,
)
from . import server_routes # noqa: F401 - registers /moonpack/* HTTP routes
from . import prompt_library_routes # noqa: F401 - registers /moonpack/prompt_library routes
log = logging.getLogger("MoonPack")
@@ -60,6 +65,7 @@ NODE_CLASS_MAPPINGS = {
**csi_class_mappings,
**mll_class_mappings,
**tb_class_mappings,
**pl_class_mappings,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -74,6 +80,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**csi_display_mappings,
**mll_display_mappings,
**tb_display_mappings,
**pl_display_mappings,
}
# Legacy aliases keep workflows saved with v0.1 keys loadable. Remove in 1.0.
+44
View File
@@ -0,0 +1,44 @@
class PromptLibrary:
"""A managed text box: save, update and recall named prompts from a shared library."""
DESCRIPTION = (
"A multiline text box that doubles as a searchable prompt library. Save the "
"current text under a name (with an optional description/tags), reload any "
"saved prompt from the dropdown, or update/delete it. The STRING output is "
"always whatever is currently in the text box."
)
SEARCH_ALIASES = ["prompt", "library", "preset", "text", "multiline"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {
"default": "", "multiline": True,
"tooltip": "The prompt text. This is exactly what gets output.",
}),
},
"optional": {
# Set by the frontend when a library entry is loaded/saved, so
# reopening the workflow shows which entry is active. Not read
# by the backend: the output is always `text`, verbatim.
"entry_name": ("STRING", {"default": "", "forceInput": False}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "get_text"
CATEGORY = "MoonPack/string"
def get_text(self, text="", entry_name=""):
return (text,)
NODE_CLASS_MAPPINGS = {
"MoonPack_PromptLibrary": PromptLibrary,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MoonPack_PromptLibrary": "Prompt Library",
}
+67
View File
@@ -0,0 +1,67 @@
"""HTTP routes backing the Prompt Library node's dropdown and save/update/delete buttons.
Import-guarded: outside a running ComfyUI (tests, linting) this module is a
no-op instead of an ImportError.
"""
import logging
from . import prompt_library_store as store
log = logging.getLogger("MoonPack")
def _register():
from aiohttp import web
from server import PromptServer
routes = PromptServer.instance.routes
@routes.get("/moonpack/prompt_library")
async def moonpack_list_prompts(_request):
return web.json_response({"entries": store.load_library()})
@routes.post("/moonpack/prompt_library")
async def moonpack_add_prompt(request):
body = await request.json()
name = str(body.get("name", "")).strip()
if not name:
return web.json_response({"error": "Name is required."}, status=400)
try:
entries = store.add_entry(
name, str(body.get("description", "")), str(body.get("tags", "")),
str(body.get("text", "")),
)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
return web.json_response({"entries": entries})
@routes.put("/moonpack/prompt_library/{name}")
async def moonpack_update_prompt(request):
body = await request.json()
try:
entries = store.update_entry(
request.match_info["name"], str(body.get("description", "")),
str(body.get("tags", "")), str(body.get("text", "")),
)
except KeyError as exc:
return web.json_response({"error": str(exc)}, status=404)
return web.json_response({"entries": entries})
@routes.delete("/moonpack/prompt_library/{name}")
async def moonpack_delete_prompt(request):
try:
entries = store.delete_entry(request.match_info["name"])
except KeyError as exc:
return web.json_response({"error": str(exc)}, status=404)
return web.json_response({"entries": entries})
return True
try:
REGISTERED = _register()
except Exception: # noqa: BLE001 - a broken route must not stop node loading
REGISTERED = False
log.warning("MoonPack: could not register HTTP routes; the Prompt Library "
"node's dropdown/save buttons will not work.", exc_info=True)
+83
View File
@@ -0,0 +1,83 @@
"""Pure JSON-backed CRUD for the Prompt Library node's saved entries.
Kept import-free of ComfyUI (except folder_paths, imported lazily inside
_library_path) so the CRUD logic is testable without a running server.
"""
import json
import logging
import os
log = logging.getLogger("MoonPack")
LIBRARY_FILENAME = "moonpack_prompt_library.json"
def _library_path() -> str:
import folder_paths
return os.path.join(folder_paths.get_user_directory(), LIBRARY_FILENAME)
def load_library() -> list:
"""Returns every saved entry, or [] if the file is missing or unreadable."""
path = _library_path()
if not os.path.isfile(path):
return []
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
log.warning("MoonPack Prompt Library: could not read %s; treating as empty.", path)
return []
entries = data.get("entries") if isinstance(data, dict) else None
return entries if isinstance(entries, list) else []
def save_library(entries: list) -> None:
path = _library_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
json.dump({"entries": entries}, handle, indent=2, ensure_ascii=False)
def find_entry(entries: list, name: str):
for entry in entries:
if entry.get("name") == name:
return entry
return None
def add_entry(name: str, description: str, tags: str, text: str) -> list:
"""Appends a new entry. Raises ValueError if the name is already taken."""
entries = load_library()
if find_entry(entries, name) is not None:
raise ValueError(f"An entry named '{name}' already exists.")
entries.append({"name": name, "description": description, "tags": tags, "text": text})
save_library(entries)
return entries
def update_entry(name: str, description: str, tags: str, text: str) -> list:
"""Updates description/tags/text of an existing entry. The name stays fixed.
Raises KeyError if no entry with that name exists.
"""
entries = load_library()
entry = find_entry(entries, name)
if entry is None:
raise KeyError(f"No entry named '{name}' exists.")
entry["description"] = description
entry["tags"] = tags
entry["text"] = text
save_library(entries)
return entries
def delete_entry(name: str) -> list:
"""Removes an entry by name. Raises KeyError if no entry with that name exists."""
entries = load_library()
if find_entry(entries, name) is None:
raise KeyError(f"No entry named '{name}' exists.")
entries = [entry for entry in entries if entry.get("name") != name]
save_library(entries)
return entries
+374
View File
@@ -0,0 +1,374 @@
/**
* Fetch/cache and DOM overlays for the Prompt Library node, backed by
* MoonPack's /moonpack/prompt_library routes.
*/
import { api } from "../../../scripts/api.js";
const STYLE_ID = "moonpack-prompt-chooser-style";
const CSS = `
.moonpack-prompt-chooser {
position: absolute;
z-index: 10000;
display: flex;
flex-direction: column;
width: 420px;
max-height: 60vh;
background: var(--comfy-menu-bg, #353535);
color: var(--fg-color, #fff);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 6px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
font-family: sans-serif;
font-size: 13px;
overflow: hidden;
}
.moonpack-prompt-chooser input {
flex: 0 0 auto;
margin: 6px;
padding: 6px 8px;
background: var(--comfy-input-bg, #222);
color: var(--input-text, #ddd);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
outline: none;
}
.moonpack-prompt-chooser ul {
flex: 1 1 auto;
margin: 0;
padding: 0 0 4px 0;
list-style: none;
overflow-y: auto;
}
.moonpack-prompt-chooser li {
padding: 5px 10px;
cursor: pointer;
}
.moonpack-prompt-chooser li:hover,
.moonpack-prompt-chooser li.selected {
background: var(--comfy-menu-secondary-bg, #4a4a4a);
}
.moonpack-prompt-chooser .moonpack-entry-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.moonpack-prompt-chooser .moonpack-entry-desc {
font-size: 11px;
opacity: 0.65;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.moonpack-prompt-chooser .moonpack-empty {
padding: 10px;
opacity: 0.6;
cursor: default;
}
.moonpack-prompt-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 10001;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.moonpack-prompt-dialog {
width: 380px;
background: var(--comfy-menu-bg, #353535);
color: var(--fg-color, #fff);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 6px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
font-family: sans-serif;
font-size: 13px;
padding: 14px;
}
.moonpack-prompt-dialog h3 {
margin: 0 0 10px 0;
font-size: 14px;
}
.moonpack-prompt-dialog label {
display: block;
margin: 8px 0 3px 0;
opacity: 0.8;
}
.moonpack-prompt-dialog input {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
background: var(--comfy-input-bg, #222);
color: var(--input-text, #ddd);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
outline: none;
}
.moonpack-prompt-dialog input:disabled {
opacity: 0.6;
}
.moonpack-prompt-dialog .moonpack-error {
color: #e06868;
margin-top: 8px;
min-height: 1em;
}
.moonpack-prompt-dialog .moonpack-dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.moonpack-prompt-dialog button {
padding: 6px 14px;
background: var(--comfy-input-bg, #222);
color: var(--input-text, #ddd);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
cursor: pointer;
}
.moonpack-prompt-dialog button:hover {
background: var(--comfy-menu-secondary-bg, #4a4a4a);
}
`;
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
let cache = null;
let inflight = null;
/** Returns every saved entry ({name, description, tags, text}), cached for the session. */
export function fetchPromptEntries(force = false) {
if (force) {
cache = null;
inflight = null;
}
if (cache) return Promise.resolve(cache);
if (!inflight) {
inflight = api
.fetchApi("/moonpack/prompt_library")
.then((response) => response.json())
.then((data) => {
cache = Array.isArray(data?.entries) ? data.entries : [];
return cache;
})
.catch((error) => {
console.error("[MoonPack] could not load the prompt library:", error);
inflight = null;
return [];
});
}
return inflight;
}
export function clearPromptCache() {
cache = null;
inflight = null;
}
async function request(method, path, body) {
const response = await api.fetchApi(path, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.error || `Request failed (${response.status}).`);
}
clearPromptCache();
return data.entries;
}
export const createPromptEntry = (entry) => request("POST", "/moonpack/prompt_library", entry);
export const updatePromptEntry = (name, entry) =>
request("PUT", `/moonpack/prompt_library/${encodeURIComponent(name)}`, entry);
export const deletePromptEntry = (name) =>
request("DELETE", `/moonpack/prompt_library/${encodeURIComponent(name)}`);
/**
* Opens the searchable entry picker near the given mouse event.
* `onPick` is called with the chosen entry; cancelling calls nothing.
*/
export function showPromptChooser(event, entries, onPick) {
ensureStyle();
const root = document.createElement("div");
root.className = "moonpack-prompt-chooser";
const input = document.createElement("input");
input.type = "text";
input.placeholder = "Filter prompts…";
input.spellcheck = false;
const list = document.createElement("ul");
root.append(input, list);
document.body.appendChild(root);
const left = Math.min(event?.clientX ?? 100, window.innerWidth - 440);
const top = Math.min(event?.clientY ?? 100, window.innerHeight - 260);
root.style.left = `${Math.max(8, left)}px`;
root.style.top = `${Math.max(8, top)}px`;
let shown = [];
let selected = 0;
function close() {
document.removeEventListener("pointerdown", onOutside, true);
root.remove();
}
function pick(entry) {
close();
if (entry != null) onPick(entry);
}
function onOutside(e) {
if (!root.contains(e.target)) close();
}
function render() {
const filter = input.value.trim().toLowerCase();
shown = filter
? entries.filter((entry) => (entry.name || "").toLowerCase().includes(filter))
: entries.slice();
selected = 0;
list.replaceChildren();
if (!shown.length) {
const empty = document.createElement("li");
empty.className = "moonpack-empty";
empty.textContent = entries.length ? "No match" : "Library is empty";
list.appendChild(empty);
return;
}
shown.forEach((entry, index) => {
const item = document.createElement("li");
const nameLine = document.createElement("div");
nameLine.className = "moonpack-entry-name";
nameLine.textContent = entry.name;
item.appendChild(nameLine);
if (entry.description) {
const descLine = document.createElement("div");
descLine.className = "moonpack-entry-desc";
descLine.textContent = entry.description;
item.appendChild(descLine);
}
item.title = [entry.description, entry.tags].filter(Boolean).join(" · ");
if (index === selected) item.classList.add("selected");
item.addEventListener("click", () => pick(entry));
list.appendChild(item);
});
}
function moveSelection(delta) {
if (!shown.length) return;
const items = list.querySelectorAll("li");
items[selected]?.classList.remove("selected");
selected = Math.max(0, Math.min(shown.length - 1, selected + delta));
items[selected]?.classList.add("selected");
items[selected]?.scrollIntoView({ block: "nearest" });
}
input.addEventListener("input", render);
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
moveSelection(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
moveSelection(-1);
} else if (e.key === "Enter") {
e.preventDefault();
if (shown.length) pick(shown[selected]);
}
e.stopPropagation();
});
render();
setTimeout(() => document.addEventListener("pointerdown", onOutside, true), 0);
input.focus();
}
/**
* Opens a small modal with name/description/tags fields.
* `onSubmit` receives {name, description, tags} and may return a Promise;
* a rejection's message is shown inline and the dialog stays open.
*/
export function showPromptEditDialog({ title, name = "", description = "", tags = "", lockName = false }, onSubmit) {
ensureStyle();
const backdrop = document.createElement("div");
backdrop.className = "moonpack-prompt-dialog-backdrop";
const dialog = document.createElement("div");
dialog.className = "moonpack-prompt-dialog";
backdrop.appendChild(dialog);
document.body.appendChild(backdrop);
const heading = document.createElement("h3");
heading.textContent = title;
dialog.appendChild(heading);
function field(labelText, value, disabled) {
const label = document.createElement("label");
label.textContent = labelText;
const box = document.createElement("input");
box.type = "text";
box.value = value;
box.disabled = !!disabled;
dialog.append(label, box);
return box;
}
const nameInput = field("Name", name, lockName);
const descInput = field("Description", description);
const tagsInput = field("Tags (comma-separated)", tags);
const error = document.createElement("div");
error.className = "moonpack-error";
dialog.appendChild(error);
const buttons = document.createElement("div");
buttons.className = "moonpack-dialog-buttons";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
const submitBtn = document.createElement("button");
submitBtn.textContent = "Save";
buttons.append(cancelBtn, submitBtn);
dialog.appendChild(buttons);
function close() {
backdrop.remove();
}
cancelBtn.addEventListener("click", close);
backdrop.addEventListener("pointerdown", (e) => {
if (e.target === backdrop) close();
});
submitBtn.addEventListener("click", () => {
error.textContent = "";
const result = onSubmit({
name: nameInput.value.trim(),
description: descInput.value.trim(),
tags: tagsInput.value.trim(),
});
Promise.resolve(result)
.then(close)
.catch((err) => {
error.textContent = err?.message || String(err);
});
});
(lockName ? descInput : nameInput).focus();
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Prompt Library - a managed multiline text box backed by a shared,
* server-side JSON library (see prompt_library_routes.py).
*
* The node's native `text` widget IS the output; `entry_name` is a hidden
* native widget that just remembers which library entry is loaded, so
* reopening the workflow shows the right picker label. Everything else
* (description, tags) lives server-side and is fetched fresh when needed.
*/
import { app } from "../../scripts/app.js";
import { drawButton } from "./lib/canvas_draw.js";
import {
clearPromptCache,
createPromptEntry,
deletePromptEntry,
fetchPromptEntries,
showPromptChooser,
showPromptEditDialog,
updatePromptEntry,
} from "./lib/prompt_chooser.js";
const NODE_TYPE = "MoonPack_PromptLibrary";
const MARGIN = 10;
const lineHeight = () => LiteGraph.NODE_WIDGET_HEIGHT;
let lastPointerEvent = null;
document.addEventListener("pointerdown", (e) => (lastPointerEvent = e), true);
/** A full-width clickable button row; fires `onPress` on pointer-up inside it. */
class ClickWidget {
constructor(name, getLabel, onPress) {
this.name = name;
this.type = "custom";
this.value = null;
this.options = { serialize: false };
this.getLabel = getLabel;
this.onPress = onPress;
this._bounds = [0, 0, 0, 0];
this._down = false;
// Not part of INPUT_TYPES: must be stripped before LiteGraph's default
// configure() restores widgets_values by raw index, and re-added after.
this._moonExtra = true;
}
computeSize(width) {
return [width, lineHeight() + 4];
}
draw(ctx, node, width, posY, height) {
const x = MARGIN;
const w = node.size[0] - MARGIN * 2;
drawButton(ctx, x, posY + 2, w, height - 4, this.getLabel(node));
this._bounds = [x, posY + 2, w, height - 4];
}
_hit(pos) {
const localY = pos[1] - (this.last_y ?? 0);
const [x, y, w, h] = this._bounds;
return pos[0] >= x && pos[0] <= x + w && localY >= y && localY <= y + h;
}
mouse(event, pos, node) {
const type = String(event?.type || "");
if (type.endsWith("down")) {
this._down = this._hit(pos);
return this._down;
}
if (type.endsWith("up")) {
const wasDown = this._down;
this._down = false;
if (wasDown && this._hit(pos)) this.onPress(event, node);
return wasDown;
}
return false;
}
}
/** Hides a native widget from layout/drawing while keeping it serialized. */
function hideWidget(widget) {
widget.computeSize = () => [0, -4];
widget.draw = () => {};
}
/** Inserts the picker before `text` and appends the action buttons at the end. */
function addExtraWidgets(node) {
const picker = new ClickWidget(
"moonPicker",
(n) => n.moonEntryName() || "📋 Pick a Saved Prompt…",
(event, n) => n.moonOpenChooser(event ?? lastPointerEvent),
);
const textIndex = node.widgets.findIndex((w) => w.name === "text");
node.widgets.splice(textIndex === -1 ? 0 : textIndex, 0, picker);
node.widgets.push(
new ClickWidget("moonSaveAsNew", () => "💾 Save as New",
(event, n) => n.moonSaveAsNew()),
new ClickWidget("moonUpdateSelected", () => "✏️ Update Selected",
(event, n) => n.moonUpdateSelected()),
new ClickWidget("moonDeleteSelected", () => "🗑️ Delete Selected",
(event, n) => n.moonDeleteSelected()),
);
}
function setupPromptLibrary(nodeType) {
nodeType.prototype.moonEntryName = function () {
const widget = this.widgets?.find((w) => w.name === "entry_name");
return widget ? String(widget.value ?? "") : "";
};
nodeType.prototype.moonSetEntryName = function (name) {
const widget = this.widgets?.find((w) => w.name === "entry_name");
if (widget) widget.value = name;
};
nodeType.prototype.moonTextWidget = function () {
return this.widgets?.find((w) => w.name === "text");
};
nodeType.prototype.moonOpenChooser = function (event) {
fetchPromptEntries().then((entries) => {
showPromptChooser(event, entries, (entry) => {
this.moonTextWidget().value = entry.text ?? "";
this.moonSetEntryName(entry.name ?? "");
this.setDirtyCanvas(true, true);
});
});
};
nodeType.prototype.moonSaveAsNew = function () {
showPromptEditDialog(
{ title: "Save as New Prompt" },
({ name, description, tags }) => {
if (!name) return Promise.reject(new Error("Name is required."));
return createPromptEntry({
name, description, tags, text: this.moonTextWidget().value ?? "",
}).then(() => {
this.moonSetEntryName(name);
this.setDirtyCanvas(true, true);
});
},
);
};
nodeType.prototype.moonUpdateSelected = function () {
const name = this.moonEntryName();
if (!name) {
alert("No prompt is loaded. Use 'Save as New' first.");
return;
}
fetchPromptEntries(true).then((entries) => {
const entry = entries.find((e) => e.name === name);
if (!entry) {
alert(`'${name}' no longer exists in the library. Use 'Save as New' instead.`);
return;
}
showPromptEditDialog(
{
title: "Update Prompt",
name: entry.name,
description: entry.description,
tags: entry.tags,
lockName: true,
},
({ description, tags }) =>
updatePromptEntry(name, {
description, tags, text: this.moonTextWidget().value ?? "",
}),
);
});
};
nodeType.prototype.moonDeleteSelected = function () {
const name = this.moonEntryName();
if (!name) {
alert("No prompt is loaded.");
return;
}
if (!confirm(`Delete '${name}' from the prompt library? This cannot be undone.`)) return;
deletePromptEntry(name)
.then(() => {
this.moonSetEntryName("");
this.setDirtyCanvas(true, true);
})
.catch((err) => alert(err?.message || String(err)));
};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
this.serialize_widgets = true;
const entryNameWidget = this.widgets?.find((w) => w.name === "entry_name");
if (entryNameWidget) hideWidget(entryNameWidget);
addExtraWidgets(this);
clearPromptCache();
this.computeSize();
this.setDirtyCanvas(true, true);
};
// Default configure() restores widgets_values onto this.widgets by raw
// index; the picker/button widgets aren't in that saved array, so they
// must be pulled out first and reinserted after the real values land.
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function (info) {
this.widgets = (this.widgets || []).filter((w) => !w._moonExtra);
configure?.apply(this, arguments);
addExtraWidgets(this);
this.computeSize();
this.setDirtyCanvas(true, true);
};
}
app.registerExtension({
name: "comfyui.moonpack.prompt-library",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === NODE_TYPE) {
setupPromptLibrary(nodeType);
}
},
});