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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user