Every node id, input name and existing widget position is unchanged, so saved
workflows load; new inputs are appended only.
Wildcards
- core/wildcards: Dynamic Prompts / Impact Pack grammar, tag queries, variables,
conditionals, concept / verbatim / loose bindings, reproducible seeds.
Wired into every enhancer node; reads ComfyUI/models/wildcards.
- New nodes: Wildcard Processor, Semantic Variation (direction axes),
Concept Survival Check. Starter lists in wildcards/.
- Random choices use a private generator; global random state is untouched.
Platform profiles
- core/profiles + profiles/*.yaml: one file per model (format, length,
negative prompt or none, quality tokens, text and dialogue syntax, vendor
rewriter prompt fetched once and cached). profiles_user/ overrides by id.
- New node: Platform Prompt Rewriter (prose, tags, edit instructions, JSON,
MiniMax H3 fields, Hailuo brackets), with validators for each format.
- Text-to-Image and Image-to-Image read profiles through platforms.py;
corrected facts for FLUX, Kontext, Chroma, Wan, AuraFlow, Kolors, NoobAI,
Qwen-Image, HunyuanImage. Negative prompt is empty for models without one.
- Hosted / API models: Seedream 5, Nano Banana, GPT Image, Midjourney V8,
FLUX.2 pro, Ideogram 3, Recraft V4, Seedance 2.0 / 2.5, Kling 3.0, Veo 3.1,
Gemini Omni Flash, Runway, Wan 3.0, Luma, hosted H3, Grok, Vidu, PixVerse.
Reports say how to switch the vendor's own prompt rewriting off.
- Illustrious: spaced tags, quality tags last, 25-80 words (OnomaAI paper).
Video nodes
- target_model and duration_seconds appended to the three video expanders.
Default target is MiniMax H3; wan22_video is the original path, unchanged.
A workflow saved earlier loads with the default until the input is set.
Also included: the Alice, Ideogram Magic Prompt and prompt-database nodes that
were already registered in __init__.py but had not been committed.
Fixes: v1 {animal} / {label:a|b} wildcards were inert in the video nodes;
Image-to-Video save_to_file raised NameError; T2I padded short-prompt models.
.gitattributes added (text=auto) so CRLF working files stop showing as
whole-file changes. prompt_database.db and the cached Ideogram prompt are
git-ignored.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VpCi6nw5UECMdZ7ayYJvN
580 lines
20 KiB
Python
580 lines
20 KiB
Python
"""
|
||
Prompt Database Backend
|
||
SQLite-based storage and retrieval for prompts with full-text search.
|
||
|
||
Author: Eric Hiss (GitHub: EricRollei)
|
||
"""
|
||
|
||
import sqlite3
|
||
import os
|
||
import json
|
||
import csv
|
||
import logging
|
||
import re
|
||
from datetime import datetime
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Database file lives next to this module
|
||
DB_PATH = os.path.join(os.path.dirname(__file__), "prompt_database.db")
|
||
|
||
# Platforms matching platforms.py keys
|
||
PLATFORMS = [
|
||
"any",
|
||
"flux",
|
||
"flux_kontex",
|
||
"pixart_sigma",
|
||
"aura_flow",
|
||
"noobai",
|
||
"kolors",
|
||
"hunyuan_image",
|
||
"qwen_image",
|
||
"qwen_image_edit",
|
||
"sd_xl",
|
||
"pony",
|
||
"illustrious",
|
||
"chroma",
|
||
"wan_image",
|
||
"wan_video",
|
||
"alice_t2v",
|
||
"other",
|
||
]
|
||
|
||
|
||
def _with_profile_platforms(names):
|
||
"""The list above, unchanged and in order, plus every platform profile id it does not have yet."""
|
||
try:
|
||
from .profile_support import profile_ids
|
||
return list(names) + [profile_id for profile_id in profile_ids() if profile_id not in names]
|
||
except Exception as error: # the database must work even if the profiles folder is missing
|
||
print(f"[Prompt Database] platform profiles unavailable, using the built-in list: {error}")
|
||
return list(names)
|
||
|
||
|
||
PLATFORMS = _with_profile_platforms(PLATFORMS)
|
||
|
||
CATEGORIES = [
|
||
"any",
|
||
"character",
|
||
"landscape",
|
||
"architecture",
|
||
"abstract",
|
||
"portrait",
|
||
"action",
|
||
"still_life",
|
||
"fantasy",
|
||
"sci_fi",
|
||
"horror",
|
||
"romantic",
|
||
"cinematic",
|
||
"product",
|
||
"concept_art",
|
||
"photography",
|
||
"animation",
|
||
"other",
|
||
]
|
||
|
||
|
||
class PromptDatabase:
|
||
"""SQLite prompt database with FTS5 full-text search."""
|
||
|
||
def __init__(self, db_path: str = DB_PATH):
|
||
self.db_path = db_path
|
||
self._conn: Optional[sqlite3.Connection] = None
|
||
self._ensure_db()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Initialisation
|
||
# ------------------------------------------------------------------
|
||
|
||
def _ensure_db(self):
|
||
"""Create tables if they don't exist."""
|
||
conn = self._connect()
|
||
conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS prompts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
prompt_text TEXT NOT NULL,
|
||
negative_prompt TEXT DEFAULT '',
|
||
category TEXT DEFAULT 'any',
|
||
platform TEXT DEFAULT 'any',
|
||
tags TEXT DEFAULT '',
|
||
rating INTEGER DEFAULT 0,
|
||
source TEXT DEFAULT 'manual',
|
||
notes TEXT DEFAULT '',
|
||
favorite INTEGER DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_prompts_category ON prompts(category);
|
||
CREATE INDEX IF NOT EXISTS idx_prompts_platform ON prompts(platform);
|
||
CREATE INDEX IF NOT EXISTS idx_prompts_rating ON prompts(rating);
|
||
CREATE INDEX IF NOT EXISTS idx_prompts_favorite ON prompts(favorite);
|
||
""")
|
||
|
||
# FTS5 virtual table for fast keyword search
|
||
# Check whether it already exists before creating
|
||
row = conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' AND name='prompts_fts'"
|
||
).fetchone()
|
||
if row is None:
|
||
conn.execute("""
|
||
CREATE VIRTUAL TABLE prompts_fts USING fts5(
|
||
prompt_text,
|
||
negative_prompt,
|
||
tags,
|
||
notes,
|
||
content='prompts',
|
||
content_rowid='id'
|
||
);
|
||
""")
|
||
# Populate FTS from any pre-existing rows
|
||
conn.execute("""
|
||
INSERT INTO prompts_fts(rowid, prompt_text, negative_prompt, tags, notes)
|
||
SELECT id, prompt_text, negative_prompt, tags, notes FROM prompts;
|
||
""")
|
||
|
||
# Triggers to keep FTS in sync
|
||
conn.executescript("""
|
||
CREATE TRIGGER IF NOT EXISTS prompts_ai AFTER INSERT ON prompts BEGIN
|
||
INSERT INTO prompts_fts(rowid, prompt_text, negative_prompt, tags, notes)
|
||
VALUES (new.id, new.prompt_text, new.negative_prompt, new.tags, new.notes);
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS prompts_ad AFTER DELETE ON prompts BEGIN
|
||
INSERT INTO prompts_fts(prompts_fts, rowid, prompt_text, negative_prompt, tags, notes)
|
||
VALUES ('delete', old.id, old.prompt_text, old.negative_prompt, old.tags, old.notes);
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS prompts_au AFTER UPDATE ON prompts BEGIN
|
||
INSERT INTO prompts_fts(prompts_fts, rowid, prompt_text, negative_prompt, tags, notes)
|
||
VALUES ('delete', old.id, old.prompt_text, old.negative_prompt, old.tags, old.notes);
|
||
INSERT INTO prompts_fts(rowid, prompt_text, negative_prompt, tags, notes)
|
||
VALUES (new.id, new.prompt_text, new.negative_prompt, new.tags, new.notes);
|
||
END;
|
||
""")
|
||
|
||
conn.commit()
|
||
|
||
def _connect(self) -> sqlite3.Connection:
|
||
"""Return the persistent connection (create if needed)."""
|
||
if self._conn is None:
|
||
self._conn = sqlite3.connect(self.db_path)
|
||
self._conn.row_factory = sqlite3.Row
|
||
return self._conn
|
||
|
||
# ------------------------------------------------------------------
|
||
# CRUD
|
||
# ------------------------------------------------------------------
|
||
|
||
def save_prompt(
|
||
self,
|
||
prompt_text: str,
|
||
negative_prompt: str = "",
|
||
category: str = "any",
|
||
platform: str = "any",
|
||
tags: str = "",
|
||
rating: int = 0,
|
||
source: str = "manual",
|
||
notes: str = "",
|
||
favorite: bool = False,
|
||
) -> int:
|
||
"""Insert a prompt and return its id."""
|
||
now = datetime.now().isoformat(timespec="seconds")
|
||
conn = self._connect()
|
||
cur = conn.execute(
|
||
"""INSERT INTO prompts
|
||
(prompt_text, negative_prompt, category, platform,
|
||
tags, rating, source, notes, favorite, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
prompt_text.strip(),
|
||
negative_prompt.strip(),
|
||
category,
|
||
platform,
|
||
tags.strip(),
|
||
max(0, min(5, rating)),
|
||
source,
|
||
notes.strip(),
|
||
int(favorite),
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
conn.commit()
|
||
return cur.lastrowid
|
||
|
||
def update_prompt(self, prompt_id: int, **fields) -> bool:
|
||
"""Update specified fields on a prompt row."""
|
||
allowed = {
|
||
"prompt_text", "negative_prompt", "category", "platform",
|
||
"tags", "rating", "source", "notes", "favorite",
|
||
}
|
||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||
if not updates:
|
||
return False
|
||
updates["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||
values = list(updates.values()) + [prompt_id]
|
||
conn = self._connect()
|
||
conn.execute(f"UPDATE prompts SET {set_clause} WHERE id = ?", values)
|
||
conn.commit()
|
||
return True
|
||
|
||
def delete_prompt(self, prompt_id: int) -> bool:
|
||
"""Delete a prompt by id."""
|
||
conn = self._connect()
|
||
cur = conn.execute("DELETE FROM prompts WHERE id = ?", (prompt_id,))
|
||
conn.commit()
|
||
return cur.rowcount > 0
|
||
|
||
def get_prompt(self, prompt_id: int) -> Optional[Dict]:
|
||
"""Get a single prompt by id."""
|
||
conn = self._connect()
|
||
row = conn.execute("SELECT * FROM prompts WHERE id = ?", (prompt_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Search
|
||
# ------------------------------------------------------------------
|
||
|
||
def search(
|
||
self,
|
||
query: str = "",
|
||
category: str = "any",
|
||
platform: str = "any",
|
||
min_rating: int = 0,
|
||
favorites_only: bool = False,
|
||
tags_filter: str = "",
|
||
limit: int = 20,
|
||
offset: int = 0,
|
||
order_by: str = "newest",
|
||
) -> Tuple[List[Dict], int]:
|
||
"""
|
||
Search prompts. Returns (results, total_count).
|
||
|
||
query: FTS5 keyword search (supports AND, OR, NOT, "phrases")
|
||
category / platform: filter or 'any' for all
|
||
min_rating: 0-5
|
||
tags_filter: comma-separated tags – all must be present
|
||
order_by: newest | oldest | rating | random
|
||
"""
|
||
conditions = []
|
||
params: list = []
|
||
use_fts = bool(query.strip())
|
||
|
||
if use_fts:
|
||
# Sanitise the user query for FTS5
|
||
safe_query = self._sanitise_fts_query(query)
|
||
conditions.append("p.id IN (SELECT rowid FROM prompts_fts WHERE prompts_fts MATCH ?)")
|
||
params.append(safe_query)
|
||
|
||
if category and category != "any":
|
||
conditions.append("p.category = ?")
|
||
params.append(category)
|
||
|
||
if platform and platform != "any":
|
||
conditions.append("p.platform = ?")
|
||
params.append(platform)
|
||
|
||
if min_rating > 0:
|
||
conditions.append("p.rating >= ?")
|
||
params.append(min_rating)
|
||
|
||
if favorites_only:
|
||
conditions.append("p.favorite = 1")
|
||
|
||
if tags_filter.strip():
|
||
for tag in [t.strip().lower() for t in tags_filter.split(",") if t.strip()]:
|
||
conditions.append("LOWER(p.tags) LIKE ?")
|
||
params.append(f"%{tag}%")
|
||
|
||
where = " AND ".join(conditions) if conditions else "1=1"
|
||
|
||
order_map = {
|
||
"newest": "p.created_at DESC",
|
||
"oldest": "p.created_at ASC",
|
||
"rating": "p.rating DESC, p.created_at DESC",
|
||
"random": "RANDOM()",
|
||
}
|
||
order = order_map.get(order_by, "p.created_at DESC")
|
||
|
||
conn = self._connect()
|
||
# Count
|
||
count_row = conn.execute(
|
||
f"SELECT COUNT(*) FROM prompts p WHERE {where}", params
|
||
).fetchone()
|
||
total = count_row[0] if count_row else 0
|
||
|
||
# Results
|
||
rows = conn.execute(
|
||
f"SELECT p.* FROM prompts p WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?",
|
||
params + [limit, offset],
|
||
).fetchall()
|
||
|
||
results = [dict(r) for r in rows]
|
||
return results, total
|
||
|
||
@staticmethod
|
||
def _sanitise_fts_query(raw: str) -> str:
|
||
"""
|
||
Turn a user-typed search string into a valid FTS5 query.
|
||
Wraps each word with quotes and joins with OR so partial phrases
|
||
are forgiving. Quoted phrases from the user are kept intact.
|
||
"""
|
||
# Preserve user-quoted phrases
|
||
phrases = re.findall(r'"([^"]+)"', raw)
|
||
remainder = re.sub(r'"[^"]*"', '', raw)
|
||
tokens = [t.strip() for t in remainder.split() if t.strip()]
|
||
parts = [f'"{p}"' for p in phrases]
|
||
parts += [f'"{t}"' for t in tokens]
|
||
return " OR ".join(parts) if parts else '""'
|
||
|
||
# ------------------------------------------------------------------
|
||
# Import
|
||
# ------------------------------------------------------------------
|
||
|
||
def import_from_text_file(
|
||
self,
|
||
file_path: str,
|
||
category: str = "any",
|
||
platform: str = "any",
|
||
tags: str = "",
|
||
delimiter: str = "newline",
|
||
skip_duplicates: bool = True,
|
||
) -> Tuple[int, int, List[str]]:
|
||
"""
|
||
Import prompts from a text file.
|
||
|
||
delimiter:
|
||
'newline' – one prompt per line
|
||
'double_newline' – prompts separated by blank lines
|
||
'paragraph' – same as double_newline
|
||
|
||
Returns (imported_count, skipped_count, errors)
|
||
"""
|
||
if not os.path.isfile(file_path):
|
||
return 0, 0, [f"File not found: {file_path}"]
|
||
|
||
errors: List[str] = []
|
||
try:
|
||
with open(file_path, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
except Exception as e:
|
||
return 0, 0, [f"Error reading file: {e}"]
|
||
|
||
if delimiter in ("double_newline", "paragraph"):
|
||
raw_prompts = re.split(r"\n\s*\n", content)
|
||
else:
|
||
raw_prompts = content.splitlines()
|
||
|
||
prompts = [p.strip() for p in raw_prompts if p.strip()]
|
||
return self._bulk_insert(prompts, category, platform, tags, "import_txt", skip_duplicates, errors)
|
||
|
||
def import_from_csv(
|
||
self,
|
||
file_path: str,
|
||
category: str = "any",
|
||
platform: str = "any",
|
||
tags: str = "",
|
||
skip_duplicates: bool = True,
|
||
) -> Tuple[int, int, List[str]]:
|
||
"""
|
||
Import from CSV. Expected columns (header row required):
|
||
prompt_text (required), negative_prompt, category, platform, tags, rating, notes
|
||
Missing columns use defaults.
|
||
"""
|
||
if not os.path.isfile(file_path):
|
||
return 0, 0, [f"File not found: {file_path}"]
|
||
|
||
errors: List[str] = []
|
||
imported = 0
|
||
skipped = 0
|
||
|
||
try:
|
||
with open(file_path, "r", encoding="utf-8", newline="") as f:
|
||
reader = csv.DictReader(f)
|
||
for row_num, row in enumerate(reader, start=2):
|
||
text = (row.get("prompt_text") or "").strip()
|
||
if not text:
|
||
errors.append(f"Row {row_num}: empty prompt_text, skipped")
|
||
skipped += 1
|
||
continue
|
||
|
||
if skip_duplicates and self._exists(text):
|
||
skipped += 1
|
||
continue
|
||
|
||
self.save_prompt(
|
||
prompt_text=text,
|
||
negative_prompt=(row.get("negative_prompt") or "").strip(),
|
||
category=row.get("category", category).strip() or category,
|
||
platform=row.get("platform", platform).strip() or platform,
|
||
tags=row.get("tags", tags).strip() or tags,
|
||
rating=int(row.get("rating", 0) or 0),
|
||
source="import_csv",
|
||
notes=(row.get("notes") or "").strip(),
|
||
)
|
||
imported += 1
|
||
except Exception as e:
|
||
errors.append(f"CSV parse error: {e}")
|
||
|
||
return imported, skipped, errors
|
||
|
||
def import_from_json(
|
||
self,
|
||
file_path: str,
|
||
category: str = "any",
|
||
platform: str = "any",
|
||
tags: str = "",
|
||
skip_duplicates: bool = True,
|
||
) -> Tuple[int, int, List[str]]:
|
||
"""
|
||
Import from JSON. Expects either:
|
||
- A JSON array of strings (plain prompts)
|
||
- A JSON array of objects with at least {"prompt_text": "..."}
|
||
"""
|
||
if not os.path.isfile(file_path):
|
||
return 0, 0, [f"File not found: {file_path}"]
|
||
|
||
errors: List[str] = []
|
||
imported = 0
|
||
skipped = 0
|
||
|
||
try:
|
||
with open(file_path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except Exception as e:
|
||
return 0, 0, [f"JSON parse error: {e}"]
|
||
|
||
if not isinstance(data, list):
|
||
return 0, 0, ["JSON root must be an array"]
|
||
|
||
for i, item in enumerate(data):
|
||
if isinstance(item, str):
|
||
text = item.strip()
|
||
neg = ""
|
||
item_cat = category
|
||
item_plat = platform
|
||
item_tags = tags
|
||
item_rating = 0
|
||
item_notes = ""
|
||
elif isinstance(item, dict):
|
||
text = (item.get("prompt_text") or item.get("prompt") or "").strip()
|
||
neg = (item.get("negative_prompt") or "").strip()
|
||
item_cat = (item.get("category") or category).strip()
|
||
item_plat = (item.get("platform") or platform).strip()
|
||
item_tags = (item.get("tags") or tags).strip()
|
||
item_rating = int(item.get("rating", 0) or 0)
|
||
item_notes = (item.get("notes") or "").strip()
|
||
else:
|
||
errors.append(f"Item {i}: unexpected type {type(item).__name__}, skipped")
|
||
skipped += 1
|
||
continue
|
||
|
||
if not text:
|
||
skipped += 1
|
||
continue
|
||
|
||
if skip_duplicates and self._exists(text):
|
||
skipped += 1
|
||
continue
|
||
|
||
self.save_prompt(
|
||
prompt_text=text,
|
||
negative_prompt=neg,
|
||
category=item_cat,
|
||
platform=item_plat,
|
||
tags=item_tags,
|
||
rating=item_rating,
|
||
source="import_json",
|
||
notes=item_notes,
|
||
)
|
||
imported += 1
|
||
|
||
return imported, skipped, errors
|
||
|
||
# ------------------------------------------------------------------
|
||
# Stats / helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def stats(self) -> Dict:
|
||
"""Return summary statistics about the database."""
|
||
conn = self._connect()
|
||
total = conn.execute("SELECT COUNT(*) FROM prompts").fetchone()[0]
|
||
favorites = conn.execute("SELECT COUNT(*) FROM prompts WHERE favorite=1").fetchone()[0]
|
||
by_cat = {
|
||
r[0]: r[1]
|
||
for r in conn.execute(
|
||
"SELECT category, COUNT(*) FROM prompts GROUP BY category ORDER BY COUNT(*) DESC"
|
||
).fetchall()
|
||
}
|
||
by_plat = {
|
||
r[0]: r[1]
|
||
for r in conn.execute(
|
||
"SELECT platform, COUNT(*) FROM prompts GROUP BY platform ORDER BY COUNT(*) DESC"
|
||
).fetchall()
|
||
}
|
||
return {
|
||
"total_prompts": total,
|
||
"favorites": favorites,
|
||
"by_category": by_cat,
|
||
"by_platform": by_plat,
|
||
}
|
||
|
||
def _exists(self, prompt_text: str) -> bool:
|
||
"""Check if exact prompt_text already present."""
|
||
conn = self._connect()
|
||
row = conn.execute(
|
||
"SELECT 1 FROM prompts WHERE prompt_text = ? LIMIT 1",
|
||
(prompt_text.strip(),),
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
def _bulk_insert(
|
||
self,
|
||
prompts: List[str],
|
||
category: str,
|
||
platform: str,
|
||
tags: str,
|
||
source: str,
|
||
skip_duplicates: bool,
|
||
errors: List[str],
|
||
) -> Tuple[int, int, List[str]]:
|
||
"""Insert a list of plain-text prompts."""
|
||
imported = 0
|
||
skipped = 0
|
||
for text in prompts:
|
||
if skip_duplicates and self._exists(text):
|
||
skipped += 1
|
||
continue
|
||
try:
|
||
self.save_prompt(
|
||
prompt_text=text,
|
||
category=category,
|
||
platform=platform,
|
||
tags=tags,
|
||
source=source,
|
||
)
|
||
imported += 1
|
||
except Exception as e:
|
||
errors.append(f"Insert error: {e}")
|
||
skipped += 1
|
||
return imported, skipped, errors
|
||
|
||
def export_to_json(self, file_path: str, **search_kwargs) -> int:
|
||
"""Export search results (or everything) to a JSON file."""
|
||
results, _ = self.search(limit=999999, **search_kwargs)
|
||
with open(file_path, "w", encoding="utf-8") as f:
|
||
json.dump(results, f, indent=2, ensure_ascii=False)
|
||
return len(results)
|
||
|
||
def export_to_txt(self, file_path: str, **search_kwargs) -> int:
|
||
"""Export prompt texts to a plain text file, one per line."""
|
||
results, _ = self.search(limit=999999, **search_kwargs)
|
||
with open(file_path, "w", encoding="utf-8") as f:
|
||
for r in results:
|
||
f.write(r["prompt_text"] + "\n")
|
||
return len(results)
|