Add files via upload
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -1,2 +1,152 @@
|
||||
# ComfyUI-Prompt-DB
|
||||
A powerful Prompt and LoRA database manager with a Web UI for ComfyUI
|
||||
# 📦 ComfyUI Prompt Database
|
||||
|
||||
A powerful prompt management system for ComfyUI. Store, categorize, search, and combine prompts with full LoRA integration — all from a beautiful built-in web UI.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🗂️ Prompt Management (Web UI)
|
||||
- **Categories** — organize prompts into named groups
|
||||
- **Create / Edit / Delete** prompts with a slide-out drawer
|
||||
- **Search** across names, text, and categories
|
||||
- **Drag & Drop** — reorder prompts and move them between categories
|
||||
- **Grid & List views** — toggle layout with persistent preference
|
||||
- **Sorting** — by name (A→Z / Z→A), newest, oldest, category, or custom order
|
||||
- **Export / Import** — backup your entire database as JSON; import with **Merge** (append new, skip duplicates) or **Overwrite**
|
||||
- **Keyboard shortcuts** — `Ctrl+N` new prompt, `/` focus search, `Esc` close
|
||||
|
||||
### 📖 LoRA Integration
|
||||
- **Attach LoRAs** to any prompt with custom strength values (supports negative values)
|
||||
- **LoRA auto-discovery** — scans your ComfyUI `loras` folder
|
||||
- **Per-LoRA enable/disable** toggle in Apply Lora Stack (inline circle ●)
|
||||
- **Strength adjustment** — ◀ ▶ arrows or manual input, range -2.0 to 2.0
|
||||
- **Reset to defaults** — restore original prompt-defined LoRA strengths
|
||||
|
||||
### 🔗 Node System
|
||||
Four nodes that work together:
|
||||
|
||||
| Node | Purpose |
|
||||
|------|---------|
|
||||
| **Prompt DB Manager** | Opens the web UI for managing your database |
|
||||
| **Prompt Selector** | Pick one prompt from a category → outputs text + LoRA stack |
|
||||
| **Prompt Combiner** | Connect multiple selectors → combines all prompts into one string + merged LoRA stack |
|
||||
| **Apply Lora Stack** | Takes a LoRA stack and applies it to model + clip |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### ComfyUI Manager
|
||||
Search for **"Prompt Database"** in the ComfyUI Manager and install.
|
||||
|
||||
### Manual
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes/
|
||||
git clone https://github.com/your-username/comfyui-prompt-db.git
|
||||
```
|
||||
|
||||
Restart ComfyUI. No additional dependencies required.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Nodes
|
||||
|
||||
### Prompt DB Manager
|
||||
Opens the Prompt Database web interface. Add it to your workflow and click the **"📂 Open Manager"** button.
|
||||
|
||||
**Outputs:** None (management only)
|
||||
|
||||
---
|
||||
|
||||
### Prompt Selector
|
||||
Select a single prompt from a category.
|
||||
|
||||
**Outputs:**
|
||||
| Output | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `prompt_text` | STRING | The selected prompt's text |
|
||||
| `db_selection` | DB_SELECTION | Category name (connect to Combiner) |
|
||||
| `lora_stack` | LORA_STACK | LoRAs attached to this prompt |
|
||||
|
||||
---
|
||||
|
||||
### Prompt Combiner
|
||||
Accepts multiple `DB_SELECTION` inputs (from Prompt Selectors). Each connected input shows a dropdown for selecting a prompt within that category. Combines all selected prompts into one string.
|
||||
|
||||
**Inputs:**
|
||||
- `separator` — how to join prompts: `, ` / newline / space / none
|
||||
- `selection_1..N` — DB_SELECTION inputs (dynamic, auto-expands)
|
||||
|
||||
**Outputs:**
|
||||
| Output | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `combined_prompt` | STRING | All selected prompts joined by separator |
|
||||
| `lora_stack` | LORA_STACK | Merged LoRAs from all selected prompts |
|
||||
|
||||
**Features:**
|
||||
- **Inline mute toggle** (● circle) — mute individual categories without disconnecting
|
||||
- **◀ ▶ arrows** — cycle through prompts quickly
|
||||
- **Click prompt name** — opens dropdown for full selection
|
||||
- Prompts are combined **in input order** (selection_1, selection_2, ...)
|
||||
|
||||
---
|
||||
|
||||
### Apply Lora Stack
|
||||
Applies a `LORA_STACK` to model and clip.
|
||||
|
||||
**Inputs:**
|
||||
| Input | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `model` | MODEL | Base model |
|
||||
| `clip` | CLIP | CLIP model |
|
||||
| `lora_stack` | LORA_STACK | LoRAs to apply |
|
||||
|
||||
**Outputs:** `model`, `clip` (with LoRAs applied)
|
||||
|
||||
**Features:**
|
||||
- **Per-LoRA toggle** — click the circle (●) to enable/disable individual LoRAs
|
||||
- **Strength control** — ◀ ▶ arrows to adjust, supports -2.0 to 2.0 range
|
||||
- **Reset Values** button — restores all strengths to prompt defaults
|
||||
- Missing LoRA files are skipped gracefully with a console warning
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Typical Workflow
|
||||
|
||||
```
|
||||
[Prompt Selector: Character] ──► selection_1 ─┐
|
||||
[Prompt Selector: Mood] ──► selection_2 ─┤
|
||||
[Prompt Selector: Clothes] ──► selection_3 ─┼──► [Prompt Combiner] ──► combined_prompt ──► [CLIP Text Encode]
|
||||
[Prompt Selector: Action] ──► selection_4 ─┤ │
|
||||
[Prompt Selector: Location] ──► selection_5 ─┘ └── lora_stack ──► [Apply Lora Stack] ──► model/clip
|
||||
```
|
||||
|
||||
1. Add a **Prompt DB Manager** node and create your categories & prompts
|
||||
2. Add **Prompt Selector** nodes for each category
|
||||
3. Connect them to a **Prompt Combiner** to merge everything
|
||||
4. Route the `lora_stack` output to **Apply Lora Stack**
|
||||
|
||||
---
|
||||
|
||||
## 📁 Data Storage
|
||||
|
||||
Prompts are stored in `prompts.json` in the node pack directory. Back up this file or use the **Export** feature in the web UI.
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ Keyboard Shortcuts (Web UI)
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl + N` | Create new prompt |
|
||||
| `/` | Focus search bar |
|
||||
| `Esc` | Close drawer / modal |
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
ComfyUI Custom Node Pack: Prompt Database
|
||||
==========================================
|
||||
Allows storing, categorizing, and selecting prompts from a local JSON database.
|
||||
"""
|
||||
|
||||
from .nodes import PromptDBManager, PromptSelector, PromptCombiner, ApplyLoraStack
|
||||
|
||||
# Register API routes on import
|
||||
from . import api_routes # noqa: F401
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PromptDBManager": PromptDBManager,
|
||||
"PromptSelector": PromptSelector,
|
||||
"PromptCombiner": PromptCombiner,
|
||||
"ApplyLoraStack": ApplyLoraStack,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptDBManager": "Prompt DB Manager",
|
||||
"PromptSelector": "Prompt Selector",
|
||||
"PromptCombiner": "Prompt Combiner",
|
||||
"ApplyLoraStack": "Apply Lora Stack",
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
REST API routes for Prompt Database CRUD operations.
|
||||
Registered with ComfyUI's PromptServer on import.
|
||||
"""
|
||||
|
||||
import os
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
from . import db
|
||||
|
||||
HTML_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
|
||||
@routes.get("/prompt_db/manager")
|
||||
async def serve_manager(request):
|
||||
"""Serve the Prompt Database Manager HTML page."""
|
||||
html_path = os.path.join(HTML_DIR, "prompt_manager.html")
|
||||
return web.FileResponse(html_path)
|
||||
|
||||
|
||||
@routes.get("/prompt_db/categories")
|
||||
async def get_categories(request):
|
||||
categories = db.get_categories()
|
||||
return web.json_response({"categories": categories})
|
||||
|
||||
|
||||
@routes.get("/prompt_db/prompts")
|
||||
async def get_prompts(request):
|
||||
category = request.query.get("category", None)
|
||||
prompts = db.get_prompts(category)
|
||||
return web.json_response({"prompts": prompts})
|
||||
|
||||
|
||||
@routes.get("/prompt_db/all")
|
||||
async def get_all(request):
|
||||
data = db.load_db()
|
||||
return web.json_response(data)
|
||||
|
||||
|
||||
@routes.get("/prompt_db/loras")
|
||||
async def get_loras(request):
|
||||
"""Return list of available LoRA files from ComfyUI's models folder."""
|
||||
loras = db.get_available_loras()
|
||||
return web.json_response({"loras": loras})
|
||||
|
||||
|
||||
@routes.post("/prompt_db/prompt")
|
||||
async def add_prompt(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
category = body.get("category", "")
|
||||
name = body.get("name", "")
|
||||
text = body.get("text", "")
|
||||
loras = body.get("loras", [])
|
||||
|
||||
if not category or not name:
|
||||
return web.json_response(
|
||||
{"error": "category and name are required"}, status=400
|
||||
)
|
||||
|
||||
prompt = db.add_prompt(category, name, text, loras=loras)
|
||||
return web.json_response({"prompt": prompt})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.put("/prompt_db/prompt/{prompt_id}")
|
||||
async def update_prompt(request):
|
||||
try:
|
||||
prompt_id = request.match_info["prompt_id"]
|
||||
body = await request.json()
|
||||
result = db.update_prompt(
|
||||
prompt_id,
|
||||
category=body.get("category"),
|
||||
name=body.get("name"),
|
||||
text=body.get("text"),
|
||||
loras=body.get("loras"),
|
||||
)
|
||||
if result:
|
||||
return web.json_response({"prompt": result})
|
||||
return web.json_response({"error": "Prompt not found"}, status=404)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.delete("/prompt_db/prompt/{prompt_id}")
|
||||
async def delete_prompt(request):
|
||||
try:
|
||||
prompt_id = request.match_info["prompt_id"]
|
||||
if db.delete_prompt(prompt_id):
|
||||
return web.json_response({"success": True})
|
||||
return web.json_response({"error": "Prompt not found"}, status=404)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.post("/prompt_db/category")
|
||||
async def add_category(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
name = body.get("name", "")
|
||||
if not name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
if db.add_category(name):
|
||||
return web.json_response({"success": True})
|
||||
return web.json_response({"error": "Category already exists"}, status=409)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.delete("/prompt_db/category/{name}")
|
||||
async def delete_category(request):
|
||||
try:
|
||||
name = request.match_info["name"]
|
||||
if db.delete_category(name):
|
||||
return web.json_response({"success": True})
|
||||
return web.json_response({"error": "Category not found"}, status=404)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.put("/prompt_db/categories/reorder")
|
||||
async def reorder_categories(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
ordered = body.get("categories", [])
|
||||
if not ordered:
|
||||
return web.json_response({"error": "categories list required"}, status=400)
|
||||
if db.reorder_categories(ordered):
|
||||
return web.json_response({"success": True})
|
||||
return web.json_response(
|
||||
{"error": "Category list does not match existing categories"}, status=400
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.put("/prompt_db/prompts/reorder")
|
||||
async def reorder_prompts(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
ordered_ids = body.get("prompt_ids", [])
|
||||
if not ordered_ids:
|
||||
return web.json_response({"error": "prompt_ids list required"}, status=400)
|
||||
if db.reorder_prompts(ordered_ids):
|
||||
return web.json_response({"success": True})
|
||||
return web.json_response({"error": "Reorder failed"}, status=400)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.get("/prompt_db/export")
|
||||
async def export_db_route(request):
|
||||
try:
|
||||
data = db.export_db()
|
||||
return web.json_response(data)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@routes.post("/prompt_db/import")
|
||||
async def import_db_route(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "merge")
|
||||
incoming = body.get("data")
|
||||
if not incoming or "categories" not in incoming or "prompts" not in incoming:
|
||||
return web.json_response(
|
||||
{"error": "Invalid import data: must have categories and prompts"},
|
||||
status=400,
|
||||
)
|
||||
stats = db.import_db(incoming, mode)
|
||||
return web.json_response({"success": True, **stats})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Thread-safe JSON database for storing prompts with categories.
|
||||
Prompts can optionally have LoRA attachments.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import threading
|
||||
|
||||
DB_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prompts.json")
|
||||
_lock = threading.Lock()
|
||||
|
||||
DEFAULT_DB = {
|
||||
"categories": ["Location", "Character", "Style", "Mood"],
|
||||
"prompts": []
|
||||
}
|
||||
|
||||
|
||||
def load_db():
|
||||
"""Load the database from disk. Returns default if file missing or corrupted."""
|
||||
with _lock:
|
||||
if not os.path.exists(DB_FILE):
|
||||
save_db_unsafe(DEFAULT_DB)
|
||||
return DEFAULT_DB.copy()
|
||||
try:
|
||||
with open(DB_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if "categories" not in data or "prompts" not in data:
|
||||
data = DEFAULT_DB.copy()
|
||||
save_db_unsafe(data)
|
||||
# Ensure all prompts have a loras field
|
||||
for p in data.get("prompts", []):
|
||||
if "loras" not in p:
|
||||
p["loras"] = []
|
||||
return data
|
||||
except (json.JSONDecodeError, IOError):
|
||||
save_db_unsafe(DEFAULT_DB)
|
||||
return DEFAULT_DB.copy()
|
||||
|
||||
|
||||
def save_db_unsafe(data):
|
||||
"""Save database without acquiring lock (caller must hold lock)."""
|
||||
with open(DB_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def save_db(data):
|
||||
"""Save database to disk (thread-safe)."""
|
||||
with _lock:
|
||||
save_db_unsafe(data)
|
||||
|
||||
|
||||
def get_categories():
|
||||
"""Return list of category names."""
|
||||
data = load_db()
|
||||
return data.get("categories", [])
|
||||
|
||||
|
||||
def get_prompts(category=None):
|
||||
"""Return prompts, optionally filtered by category."""
|
||||
data = load_db()
|
||||
prompts = data.get("prompts", [])
|
||||
if category:
|
||||
prompts = [p for p in prompts if p.get("category") == category]
|
||||
return prompts
|
||||
|
||||
|
||||
def get_prompt_by_id(prompt_id):
|
||||
"""Return a single prompt by its ID, or None."""
|
||||
data = load_db()
|
||||
for p in data.get("prompts", []):
|
||||
if p.get("id") == prompt_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def get_prompt_by_name(name, category=None):
|
||||
"""Return a single prompt by its name and optionally category, or None."""
|
||||
data = load_db()
|
||||
for p in data.get("prompts", []):
|
||||
if p.get("name") == name:
|
||||
if category is None or p.get("category") == category:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def add_prompt(category, name, text, loras=None):
|
||||
"""Add a new prompt. Returns the new prompt dict."""
|
||||
with _lock:
|
||||
data = _load_unsafe()
|
||||
prompt = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"category": category,
|
||||
"name": name,
|
||||
"text": text,
|
||||
"loras": loras or [],
|
||||
}
|
||||
data["prompts"].append(prompt)
|
||||
save_db_unsafe(data)
|
||||
return prompt
|
||||
|
||||
|
||||
def update_prompt(prompt_id, category=None, name=None, text=None, loras=None):
|
||||
"""Update an existing prompt by ID. Returns updated prompt or None."""
|
||||
with _lock:
|
||||
data = _load_unsafe()
|
||||
for p in data["prompts"]:
|
||||
if p["id"] == prompt_id:
|
||||
if category is not None:
|
||||
p["category"] = category
|
||||
if name is not None:
|
||||
p["name"] = name
|
||||
if text is not None:
|
||||
p["text"] = text
|
||||
if loras is not None:
|
||||
p["loras"] = loras
|
||||
save_db_unsafe(data)
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def delete_prompt(prompt_id):
|
||||
"""Delete a prompt by ID. Returns True if found and deleted."""
|
||||
with _lock:
|
||||
data = _load_unsafe()
|
||||
original_len = len(data["prompts"])
|
||||
data["prompts"] = [p for p in data["prompts"] if p["id"] != prompt_id]
|
||||
if len(data["prompts"]) < original_len:
|
||||
save_db_unsafe(data)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def add_category(name):
|
||||
"""Add a new category. Returns True if added, False if already exists."""
|
||||
with _lock:
|
||||
data = _load_unsafe()
|
||||
if name in data["categories"]:
|
||||
return False
|
||||
data["categories"].append(name)
|
||||
save_db_unsafe(data)
|
||||
return True
|
||||
|
||||
|
||||
def delete_category(name):
|
||||
"""Delete a category and all its prompts. Returns True if found and deleted."""
|
||||
with _lock:
|
||||
data = _load_unsafe()
|
||||
if name not in data["categories"]:
|
||||
return False
|
||||
data["categories"].remove(name)
|
||||
data["prompts"] = [p for p in data["prompts"] if p["category"] != name]
|
||||
save_db_unsafe(data)
|
||||
return True
|
||||
|
||||
|
||||
def reorder_categories(ordered_names):
|
||||
"""Reorder categories to match the given list. Returns True if successful."""
|
||||
with _lock:
|
||||
data = _load_unsafe()
|
||||
existing = set(data["categories"])
|
||||
given = set(ordered_names)
|
||||
if existing != given:
|
||||
return False
|
||||
data["categories"] = list(ordered_names)
|
||||
save_db_unsafe(data)
|
||||
return True
|
||||
|
||||
|
||||
def reorder_prompts(ordered_ids):
|
||||
"""Reorder prompts to match the given ID list. Unmentioned prompts keep their relative order at the end."""
|
||||
with _lock:
|
||||
data = _load_unsafe()
|
||||
by_id = {p["id"]: p for p in data["prompts"]}
|
||||
reordered = []
|
||||
for pid in ordered_ids:
|
||||
if pid in by_id:
|
||||
reordered.append(by_id.pop(pid))
|
||||
# Append any remaining prompts not in the ordered list
|
||||
for p in data["prompts"]:
|
||||
if p["id"] in by_id:
|
||||
reordered.append(p)
|
||||
data["prompts"] = reordered
|
||||
save_db_unsafe(data)
|
||||
return True
|
||||
|
||||
|
||||
def get_available_loras():
|
||||
"""Return list of available LoRA filenames from ComfyUI's models folder."""
|
||||
try:
|
||||
import folder_paths
|
||||
return folder_paths.get_filename_list("loras")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def export_db():
|
||||
"""Return full database dict for export."""
|
||||
return load_db()
|
||||
|
||||
|
||||
def import_db(incoming, mode="merge"):
|
||||
"""Import prompts from an incoming dict.
|
||||
mode='overwrite': replace entire DB.
|
||||
mode='merge': add new categories and prompts, skip duplicates (by name+category).
|
||||
Returns dict with stats: {categories_added, prompts_added, prompts_skipped}.
|
||||
"""
|
||||
with _lock:
|
||||
if mode == "overwrite":
|
||||
# Ensure all prompts have IDs and loras
|
||||
for p in incoming.get("prompts", []):
|
||||
if "id" not in p:
|
||||
p["id"] = str(uuid.uuid4())
|
||||
if "loras" not in p:
|
||||
p["loras"] = []
|
||||
save_db_unsafe(incoming)
|
||||
return {
|
||||
"categories_added": len(incoming.get("categories", [])),
|
||||
"prompts_added": len(incoming.get("prompts", [])),
|
||||
"prompts_skipped": 0,
|
||||
}
|
||||
|
||||
# Merge mode
|
||||
data = _load_unsafe()
|
||||
cats_added = 0
|
||||
prompts_added = 0
|
||||
prompts_skipped = 0
|
||||
|
||||
# Add missing categories
|
||||
existing_cats = set(data["categories"])
|
||||
for cat in incoming.get("categories", []):
|
||||
if cat not in existing_cats:
|
||||
data["categories"].append(cat)
|
||||
existing_cats.add(cat)
|
||||
cats_added += 1
|
||||
|
||||
# Add prompts that don't exist yet (by name + category)
|
||||
existing_prompts = {(p["name"], p["category"]) for p in data["prompts"]}
|
||||
for p in incoming.get("prompts", []):
|
||||
key = (p.get("name", ""), p.get("category", ""))
|
||||
if key in existing_prompts:
|
||||
prompts_skipped += 1
|
||||
continue
|
||||
prompt = {
|
||||
"id": p.get("id") or str(uuid.uuid4()),
|
||||
"name": p.get("name", ""),
|
||||
"category": p.get("category", ""),
|
||||
"text": p.get("text", ""),
|
||||
"loras": p.get("loras", []),
|
||||
}
|
||||
data["prompts"].append(prompt)
|
||||
existing_prompts.add(key)
|
||||
prompts_added += 1
|
||||
|
||||
save_db_unsafe(data)
|
||||
return {
|
||||
"categories_added": cats_added,
|
||||
"prompts_added": prompts_added,
|
||||
"prompts_skipped": prompts_skipped,
|
||||
}
|
||||
|
||||
|
||||
def _load_unsafe():
|
||||
"""Load database without lock (internal use only)."""
|
||||
if not os.path.exists(DB_FILE):
|
||||
save_db_unsafe(DEFAULT_DB)
|
||||
return DEFAULT_DB.copy()
|
||||
try:
|
||||
with open(DB_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Ensure all prompts have loras field
|
||||
for p in data.get("prompts", []):
|
||||
if "loras" not in p:
|
||||
p["loras"] = []
|
||||
return data
|
||||
except (json.JSONDecodeError, IOError):
|
||||
save_db_unsafe(DEFAULT_DB)
|
||||
return DEFAULT_DB.copy()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
ComfyUI custom nodes for Prompt Database.
|
||||
"""
|
||||
|
||||
import json as _json
|
||||
import re as _re
|
||||
import folder_paths
|
||||
|
||||
|
||||
def _clean_name(name):
|
||||
"""Strip the 📖 LoRA marker suffix added by the JS frontend."""
|
||||
return _re.sub(r'\s*📖$', '', name).strip()
|
||||
import comfy.sd
|
||||
import comfy.utils
|
||||
from . import db
|
||||
|
||||
|
||||
class PromptDBManager:
|
||||
"""
|
||||
Management node — provides a UI button to open the prompt database manager.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "manage"
|
||||
CATEGORY = "Prompt Database"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def manage(self, unique_id=None):
|
||||
return {}
|
||||
|
||||
|
||||
class PromptSelector:
|
||||
"""
|
||||
Selector node — pick a category and prompt.
|
||||
Outputs:
|
||||
- prompt_text (STRING): the selected prompt text (no LoRA tags)
|
||||
- db_selection (DB_SELECTION): the category name (for PromptCombiner)
|
||||
- lora_stack (LORA_STACK): LoRAs attached to the selected prompt
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
categories = db.get_categories()
|
||||
if not categories:
|
||||
categories = ["(no categories)"]
|
||||
|
||||
all_prompts = db.get_prompts()
|
||||
prompt_names = list({p["name"] for p in all_prompts})
|
||||
if not prompt_names:
|
||||
prompt_names = ["(no prompts)"]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"category": (categories, {"default": categories[0]}),
|
||||
"prompt_name": (prompt_names, {"default": prompt_names[0]}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "DB_SELECTION", "LORA_STACK")
|
||||
RETURN_NAMES = ("prompt_text", "db_selection", "lora_stack")
|
||||
FUNCTION = "select_prompt"
|
||||
CATEGORY = "Prompt Database"
|
||||
|
||||
def select_prompt(self, category, prompt_name):
|
||||
prompt_name = _clean_name(prompt_name)
|
||||
prompt = db.get_prompt_by_name(prompt_name, category)
|
||||
if not prompt:
|
||||
prompt = db.get_prompt_by_name(prompt_name)
|
||||
if not prompt:
|
||||
return ("", category, [])
|
||||
|
||||
text = prompt["text"]
|
||||
loras = prompt.get("loras", [])
|
||||
# Normalize lora entries
|
||||
lora_stack = []
|
||||
for l in loras:
|
||||
lora_stack.append({
|
||||
"name": l.get("name", ""),
|
||||
"strength": float(l.get("strength", 1.0)),
|
||||
})
|
||||
|
||||
return (text, category, lora_stack)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, category, prompt_name):
|
||||
prompt_name = _clean_name(prompt_name)
|
||||
prompt = db.get_prompt_by_name(prompt_name, category)
|
||||
if prompt:
|
||||
return prompt["text"] + str(prompt.get("loras", []))
|
||||
return ""
|
||||
|
||||
|
||||
class PromptCombiner:
|
||||
"""
|
||||
Combiner node — accepts multiple DB_SELECTION inputs (category names),
|
||||
shows a prompt dropdown per category inside the node,
|
||||
and combines selected prompts into one STRING + merged LORA_STACK.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"separator": (
|
||||
["comma_space", "newline", "space", "none"],
|
||||
{"default": "comma_space"},
|
||||
),
|
||||
"prompt_selections": (
|
||||
"STRING",
|
||||
{"default": "{}", "multiline": False},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"selection_1": ("DB_SELECTION", {"forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "LORA_STACK")
|
||||
RETURN_NAMES = ("combined_prompt", "lora_stack")
|
||||
FUNCTION = "combine"
|
||||
CATEGORY = "Prompt Database"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def combine(self, separator="comma_space", prompt_selections="{}", **kwargs):
|
||||
sep_map = {
|
||||
"comma_space": ", ",
|
||||
"newline": "\n",
|
||||
"space": " ",
|
||||
"none": "",
|
||||
}
|
||||
sep = sep_map.get(separator, ", ")
|
||||
|
||||
try:
|
||||
selections = _json.loads(prompt_selections)
|
||||
except _json.JSONDecodeError:
|
||||
selections = {}
|
||||
|
||||
texts = []
|
||||
merged_loras = []
|
||||
seen_loras = set()
|
||||
|
||||
selection_keys = [k for k in kwargs.keys() if k.startswith("selection_")]
|
||||
selection_keys.sort(key=lambda k: int(k.split("_")[1]))
|
||||
|
||||
for key in selection_keys:
|
||||
category = kwargs[key]
|
||||
if not category:
|
||||
continue
|
||||
prompt_name = _clean_name(selections.get(key, ""))
|
||||
if not prompt_name:
|
||||
continue
|
||||
prompt = db.get_prompt_by_name(prompt_name, category)
|
||||
if prompt:
|
||||
texts.append(prompt["text"])
|
||||
# Collect LoRAs (deduplicate by name)
|
||||
for l in prompt.get("loras", []):
|
||||
lname = l.get("name", "")
|
||||
if lname and lname not in seen_loras:
|
||||
seen_loras.add(lname)
|
||||
merged_loras.append({
|
||||
"name": lname,
|
||||
"strength": float(l.get("strength", 1.0)),
|
||||
})
|
||||
|
||||
combined = sep.join(texts)
|
||||
return (combined, merged_loras)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, separator, prompt_selections="{}", **kwargs):
|
||||
return prompt_selections + str(kwargs)
|
||||
|
||||
|
||||
class ApplyLoraStack:
|
||||
"""
|
||||
Applies LoRAs from a LORA_STACK to a model and clip.
|
||||
The lora_config hidden widget stores per-LoRA overrides
|
||||
(enable/disable + custom strength).
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"lora_config": (
|
||||
"STRING",
|
||||
{"default": "{}", "multiline": False},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"lora_stack": ("LORA_STACK",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP")
|
||||
RETURN_NAMES = ("model", "clip")
|
||||
FUNCTION = "apply_loras"
|
||||
CATEGORY = "Prompt Database"
|
||||
|
||||
def apply_loras(self, model, clip, lora_config="{}", lora_stack=None):
|
||||
if not lora_stack:
|
||||
return (model, clip)
|
||||
|
||||
# Parse overrides
|
||||
try:
|
||||
config = _json.loads(lora_config)
|
||||
except _json.JSONDecodeError:
|
||||
config = {}
|
||||
|
||||
for lora_entry in lora_stack:
|
||||
name = lora_entry.get("name", "")
|
||||
default_strength = lora_entry.get("strength", 1.0)
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Apply per-LoRA overrides from config
|
||||
override = config.get(name, {})
|
||||
if not override.get("enabled", True):
|
||||
print(f"[PromptDB] LoRA skipped (disabled): {name}")
|
||||
continue
|
||||
|
||||
strength = float(override.get("strength", default_strength))
|
||||
|
||||
# Load LoRA file
|
||||
lora_path = folder_paths.get_full_path("loras", name)
|
||||
if not lora_path:
|
||||
print(f"[PromptDB] LoRA file not found: {name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
model, clip = comfy.sd.load_lora_for_models(
|
||||
model, clip, lora, strength, strength
|
||||
)
|
||||
print(f"[PromptDB] LoRA applied: {name} (strength={strength:.2f})")
|
||||
except Exception as e:
|
||||
print(f"[PromptDB] Error loading LoRA {name}: {e}")
|
||||
|
||||
return (model, clip)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model, clip, lora_config="{}", lora_stack=None):
|
||||
return str(lora_stack) + lora_config
|
||||
Reference in New Issue
Block a user