- Added the "Model Switch" node - Updated README - Cleaned up the CHANGELOG to meet standard
618 lines
23 KiB
Python
618 lines
23 KiB
Python
# ****** ComfyUI - AF Prompt Nodes Pack - Combined Nodes
|
|
#
|
|
# Creator: Alex Furer - Co-Creator(s): Qwen3, Claude AI, DeepSeek
|
|
#
|
|
# Description: A lightweight suite of ComfyUI custom nodes for AI prompt management and history tracking.
|
|
#
|
|
# Repo: https://github.com/alFrame/ComfyUI-AF-Pack-Prompt-Nodes
|
|
#
|
|
# Issues, praise, comment, bugs, improvements: https://github.com/alFrame/ComfyUI-AF-Pack-Prompt-Nodes/issues
|
|
#
|
|
# LICENSE: MIT License
|
|
#
|
|
# Usage: https://github.com/alFrame/ComfyUI-AF-Pack-Prompt-Nodes/blob/main/README.md
|
|
#
|
|
# Feature Requests / Wet Dreams
|
|
# -
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
import folder_paths
|
|
|
|
# =============================================================================
|
|
# AF - Edit Generated Prompt Node
|
|
# =============================================================================
|
|
|
|
class AF_Edit_Generated_Prompt:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"generated_prompt": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"readonly": True
|
|
}),
|
|
"manual_or_paste_generated": ("STRING", {
|
|
"multiline": True,
|
|
"default": ""
|
|
}),
|
|
},
|
|
"optional": {
|
|
"input_text": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"forceInput": True
|
|
})
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
FUNCTION = "process"
|
|
CATEGORY = "AF - Nodes/AF - Prompt Pack"
|
|
OUTPUT_NODE = True
|
|
|
|
def process(self, generated_prompt="", manual_or_paste_generated="", input_text=""):
|
|
generated_prompt = generated_prompt or ""
|
|
manual_or_paste_generated = manual_or_paste_generated or ""
|
|
input_text = input_text or ""
|
|
|
|
if manual_or_paste_generated.strip():
|
|
output = manual_or_paste_generated
|
|
elif input_text.strip():
|
|
output = input_text
|
|
else:
|
|
output = ""
|
|
|
|
display_text = input_text if input_text else "No input connected"
|
|
|
|
return {
|
|
"ui": {"generated_prompt": [str(display_text)]},
|
|
"result": (output,)
|
|
}
|
|
|
|
# =============================================================================
|
|
# AF - Save Prompt History Node
|
|
# =============================================================================
|
|
|
|
class AF_Save_Prompt_History:
|
|
def __init__(self):
|
|
self.type = "AF Save Prompt History"
|
|
self.aux_id = "AF-SPH-001"
|
|
self.cnr_id = "prompt-save-history"
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"directory": ("STRING", {"default": "Prompt-History", "multiline": False}),
|
|
"filename": ("STRING", {"default": "default", "multiline": False}),
|
|
"project": ("STRING", {"default": "default", "multiline": False}),
|
|
},
|
|
"optional": {
|
|
"global_positive": ("STRING", {"forceInput": True}),
|
|
"global_negative": ("STRING", {"forceInput": True}),
|
|
"local_positive": ("STRING", {"forceInput": True}),
|
|
"local_negative": ("STRING", {"forceInput": True}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("global_positive", "global_negative", "local_positive", "local_negative")
|
|
FUNCTION = "save_prompts"
|
|
OUTPUT_NODE = True
|
|
CATEGORY = "AF - Nodes/AF - Prompt Pack"
|
|
|
|
def save_prompts(self, directory, filename, project, global_positive="", global_negative="", local_positive="", local_negative=""):
|
|
json_filename = f"{filename.strip()}.json"
|
|
|
|
# Get the node pack folder first
|
|
node_dir = os.path.dirname(os.path.abspath(__file__))
|
|
pack_dir = os.path.join(node_dir, directory.strip())
|
|
|
|
# Try output directory as fallback
|
|
try:
|
|
output_dir = folder_paths.get_output_directory()
|
|
output_path = os.path.join(output_dir, directory.strip())
|
|
except:
|
|
output_path = None
|
|
|
|
# Prefer node pack directory, fallback to output
|
|
library_path = pack_dir
|
|
if output_path and os.path.exists(output_path):
|
|
library_path = output_path
|
|
|
|
try:
|
|
os.makedirs(library_path, exist_ok=True)
|
|
except Exception as e:
|
|
print(f"AF Save Prompt History - Error creating directory: {e}")
|
|
return (global_positive, global_negative, local_positive, local_negative)
|
|
|
|
json_file_path = os.path.join(library_path, json_filename)
|
|
|
|
existing_data = []
|
|
if os.path.exists(json_file_path):
|
|
try:
|
|
with open(json_file_path, 'r', encoding='utf-8') as jsonfile:
|
|
existing_data = json.load(jsonfile)
|
|
if not isinstance(existing_data, list):
|
|
existing_data = []
|
|
except Exception as e:
|
|
existing_data = []
|
|
|
|
# Clean up prompts: remove trailing newlines but preserve internal ones
|
|
def clean_prompt(prompt):
|
|
if not prompt:
|
|
return ""
|
|
# Remove trailing whitespace and newlines, but preserve internal formatting
|
|
return prompt.rstrip()
|
|
|
|
# Clean the new prompts
|
|
cleaned_gp = clean_prompt(global_positive)
|
|
cleaned_gn = clean_prompt(global_negative)
|
|
cleaned_lp = clean_prompt(local_positive)
|
|
cleaned_ln = clean_prompt(local_negative)
|
|
|
|
# Check if prompts are identical to the last entry
|
|
if existing_data:
|
|
last_entry = existing_data[-1]
|
|
if (last_entry.get('global_positive', '') == cleaned_gp and
|
|
last_entry.get('global_negative', '') == cleaned_gn and
|
|
last_entry.get('local_positive', '') == cleaned_lp and
|
|
last_entry.get('local_negative', '') == cleaned_ln):
|
|
# Prompts haven't changed - skip saving
|
|
print(f"AF Save Prompt History - Skipped (no changes detected)")
|
|
return (global_positive, global_negative, local_positive, local_negative)
|
|
|
|
new_entry = {
|
|
'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
'project': project.strip(),
|
|
'global_positive': cleaned_gp,
|
|
'global_negative': cleaned_gn,
|
|
'local_positive': cleaned_lp,
|
|
'local_negative': cleaned_ln,
|
|
}
|
|
|
|
existing_data.append(new_entry)
|
|
|
|
try:
|
|
with open(json_file_path, 'w', encoding='utf-8') as jsonfile:
|
|
# Pretty formatting with indentation for human readability
|
|
json.dump(existing_data, jsonfile, indent=2, ensure_ascii=False)
|
|
print(f"AF Save Prompt History - Saved to {json_file_path}")
|
|
except Exception as e:
|
|
print(f"AF Save Prompt History - Error writing file: {e}")
|
|
|
|
return (global_positive, global_negative, local_positive, local_negative)
|
|
|
|
# =============================================================================
|
|
# AF - Load Prompt History Node
|
|
# =============================================================================
|
|
|
|
class AF_Load_Prompt_History:
|
|
"""
|
|
Load prompt history from JSON files using an index-based selection.
|
|
|
|
Usage:
|
|
- Select filename from dropdown
|
|
- Set timestamp_index (0 = newest, 1 = second newest, etc.)
|
|
- Connect outputs to Edit Generated Prompt nodes or CLIP encoders
|
|
- Connect 'info' output to Show Text node to see selection details
|
|
"""
|
|
|
|
_filename_cache = {}
|
|
_timestamp_cache = {}
|
|
_last_directory = "AF-PromptHistory"
|
|
|
|
def __init__(self):
|
|
self.type = "AF Load Prompt History"
|
|
|
|
@classmethod
|
|
def get_directory_paths(cls, directory):
|
|
"""Get both possible directory paths (output folder takes priority)"""
|
|
paths = []
|
|
|
|
# Check output folder first (user's working files)
|
|
try:
|
|
output_dir = folder_paths.get_output_directory()
|
|
output_path = os.path.join(output_dir, directory.strip())
|
|
if os.path.exists(output_path):
|
|
paths.append(output_path)
|
|
except:
|
|
pass
|
|
|
|
# Check node pack folder (bundled defaults)
|
|
node_dir = os.path.dirname(os.path.abspath(__file__))
|
|
pack_path = os.path.join(node_dir, directory.strip())
|
|
if os.path.exists(pack_path):
|
|
paths.append(pack_path)
|
|
|
|
return paths
|
|
|
|
@classmethod
|
|
def scan_filenames(cls, directory):
|
|
"""Scan directory and cache all available JSON files"""
|
|
print(f"AF Load - Scanning directory: {directory}")
|
|
paths = cls.get_directory_paths(directory)
|
|
found = {}
|
|
|
|
for path in paths:
|
|
try:
|
|
files = [f for f in os.listdir(path) if f.endswith('.json')]
|
|
for f in files:
|
|
name = f[:-5] # Remove .json extension
|
|
if name not in found:
|
|
found[name] = {"path": path, "filename": f}
|
|
print(f"AF Load - Found file: {name}")
|
|
except Exception as e:
|
|
print(f"AF Load - Error scanning {path}: {e}")
|
|
|
|
print(f"AF Load - Total files found: {len(found)}")
|
|
return found
|
|
|
|
@classmethod
|
|
def load_timestamps(cls, directory, filename):
|
|
"""Load and cache timestamps for a specific file"""
|
|
cache_key = f"{directory}::{filename}"
|
|
|
|
# Return cached if available
|
|
if cache_key in cls._timestamp_cache:
|
|
return cls._timestamp_cache[cache_key]
|
|
|
|
# Rebuild filename cache if directory changed
|
|
if directory != cls._last_directory:
|
|
cls._filename_cache = cls.scan_filenames(directory)
|
|
cls._last_directory = directory
|
|
|
|
if filename not in cls._filename_cache:
|
|
return []
|
|
|
|
info = cls._filename_cache[filename]
|
|
json_path = os.path.join(info["path"], info["filename"])
|
|
|
|
print(f"AF Load - Loading timestamps from: {json_path}")
|
|
|
|
try:
|
|
with open(json_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
if not isinstance(data, list) or not data:
|
|
return []
|
|
|
|
# Sort by timestamp (newest first)
|
|
data.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
|
|
|
# Format: "2025-10-10 | 19:29:22"
|
|
timestamps = [e['timestamp'].replace(' ', ' | ') for e in data if 'timestamp' in e]
|
|
|
|
# Cache the result
|
|
cls._timestamp_cache[cache_key] = timestamps
|
|
print(f"AF Load - Loaded {len(timestamps)} timestamps")
|
|
return timestamps
|
|
|
|
except Exception as e:
|
|
print(f"AF Load - Error loading timestamps: {e}")
|
|
return []
|
|
|
|
@classmethod
|
|
def load_prompt(cls, directory, filename, timestamp):
|
|
"""Load prompt data for a specific timestamp"""
|
|
if filename not in cls._filename_cache:
|
|
return None
|
|
|
|
info = cls._filename_cache[filename]
|
|
json_path = os.path.join(info["path"], info["filename"])
|
|
|
|
# Convert formatted timestamp back to original format
|
|
orig_ts = timestamp.replace(' | ', ' ')
|
|
|
|
try:
|
|
with open(json_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
# Find matching entry
|
|
for entry in data:
|
|
if entry.get('timestamp') == orig_ts:
|
|
return {
|
|
"global_positive": entry.get('global_positive', ''),
|
|
"global_negative": entry.get('global_negative', ''),
|
|
"local_positive": entry.get('local_positive', ''),
|
|
"local_negative": entry.get('local_negative', '')
|
|
}
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"AF Load - Error loading prompt: {e}")
|
|
return None
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# Initialize cache on first call
|
|
if not cls._filename_cache:
|
|
cls._filename_cache = cls.scan_filenames(cls._last_directory)
|
|
|
|
filenames = sorted(cls._filename_cache.keys()) if cls._filename_cache else ["none"]
|
|
|
|
return {
|
|
"required": {
|
|
"directory": ("STRING", {"default": "AF-PromptHistory"}),
|
|
"filename": (filenames,),
|
|
"timestamp_index": ("INT", {
|
|
"default": 0,
|
|
"min": 0,
|
|
"max": 999,
|
|
"step": 1,
|
|
"display": "number"
|
|
}),
|
|
"tip": ("STRING", {
|
|
"default": "💡 TIP: Connect 'info' output to Show Text node to see this selection guide!",
|
|
"multiline": True
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("global_positive", "global_negative", "local_positive", "local_negative", "info")
|
|
FUNCTION = "load_prompts"
|
|
CATEGORY = "AF - Nodes/AF - Prompt Pack"
|
|
|
|
def load_prompts(self, directory, filename, timestamp_index, tip=""):
|
|
"""
|
|
Main execution function - loads prompts based on index selection
|
|
|
|
Returns:
|
|
Tuple of (global_positive, global_negative, local_positive, local_negative, info)
|
|
- First 4 outputs: The actual prompts (connect to Edit Generated Prompt or CLIP)
|
|
- Last output 'info': Selection details and timestamp list (connect to Show Text)
|
|
"""
|
|
|
|
print(f"\nAF Load - Executing: dir={directory}, filename={filename}, index={timestamp_index}")
|
|
|
|
# Update cache if directory changed
|
|
if directory != self._last_directory:
|
|
self._filename_cache = self.scan_filenames(directory)
|
|
self._last_directory = directory
|
|
|
|
# Load timestamps for selected file
|
|
timestamps = []
|
|
if filename and filename != "none" and filename in self._filename_cache:
|
|
timestamps = self.load_timestamps(directory, filename)
|
|
|
|
# Get timestamp by index
|
|
selected_timestamp = ""
|
|
if timestamps and 0 <= timestamp_index < len(timestamps):
|
|
selected_timestamp = timestamps[timestamp_index]
|
|
|
|
# Load actual prompt data
|
|
gp = gn = lp = ln = ""
|
|
|
|
if selected_timestamp:
|
|
data = self.load_prompt(directory, filename, selected_timestamp)
|
|
if data:
|
|
gp = data["global_positive"]
|
|
gn = data["global_negative"]
|
|
lp = data["local_positive"]
|
|
ln = data["local_negative"]
|
|
print(f"AF Load - ✓ Successfully loaded: ID: {timestamp_index} | {selected_timestamp.replace(' | ', ' ')}")
|
|
else:
|
|
print(f"AF Load - ✗ Failed to load prompt data")
|
|
|
|
# Build info display text
|
|
info_lines = [
|
|
"═" * 50,
|
|
" AF - LOAD PROMPT HISTORY",
|
|
"═" * 50,
|
|
f"📄 Filename: {filename}",
|
|
f"📊 Available Timestamps: {len(timestamps)}",
|
|
""
|
|
]
|
|
|
|
if selected_timestamp:
|
|
info_lines.append(f"🔢 SELECTED: [{timestamp_index}] | {selected_timestamp}")
|
|
info_lines.append("")
|
|
info_lines.append("Global Positive:")
|
|
info_lines.append(gp if gp else "(empty)")
|
|
info_lines.append("")
|
|
info_lines.append("Global Negative:")
|
|
info_lines.append(gn if gn else "(empty)")
|
|
info_lines.append("")
|
|
info_lines.append("Local Positive:")
|
|
info_lines.append(lp if lp else "(empty)")
|
|
info_lines.append("")
|
|
info_lines.append("Local Negative:")
|
|
info_lines.append(ln if ln else "(empty)")
|
|
elif timestamps:
|
|
info_lines.append(f"⚠️ Index {timestamp_index} out of range!")
|
|
info_lines.append(f" Valid range: 0 to {len(timestamps)-1}")
|
|
else:
|
|
info_lines.append("⚠️ No timestamps found in this file")
|
|
|
|
info_text = "\n".join(info_lines)
|
|
|
|
return (gp, gn, lp, ln, info_text)
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
"""Force re-evaluation on any input change"""
|
|
import time
|
|
return time.time()
|
|
|
|
# =============================================================================
|
|
# AF - Show Text Node
|
|
# =============================================================================
|
|
|
|
class AF_Show_Text:
|
|
"""
|
|
Simple text display node - shows text in a large multiline read-only display.
|
|
Perfect for showing prompt history, info text, or any multiline content.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"text": ("STRING", {
|
|
"multiline": True,
|
|
"default": "No input connected",
|
|
"readonly": True
|
|
}),
|
|
},
|
|
"hidden": {
|
|
"unique_id": "UNIQUE_ID",
|
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
FUNCTION = "display_text"
|
|
CATEGORY = "AF - Nodes/AF - Prompt Pack"
|
|
OUTPUT_NODE = True
|
|
|
|
def display_text(self, text, unique_id=None, extra_pnginfo=None):
|
|
# Return both UI update and pass-through output
|
|
return {
|
|
"ui": {"text": [str(text)]},
|
|
"result": (text,)
|
|
}
|
|
|
|
# =============================================================================
|
|
# AF - Model Switch Node (1-based Rails, 0=AUTO)
|
|
# =============================================================================
|
|
|
|
class AF_Model_Switch:
|
|
"""
|
|
Switch between model configurations with dynamic input visibility.
|
|
Uses 1-based indexing: Rail 1, Rail 2, Rail 3, etc.
|
|
Rails 1 and 2 are permanent (connections never lost).
|
|
Rails 3-10 are dynamically added/removed.
|
|
|
|
Selected rail: 0 = AUTO (first connected), 1-10 = manual selection
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"number_of_rails": ("INT", {
|
|
"default": 2,
|
|
"min": 2,
|
|
"max": 10,
|
|
"step": 1,
|
|
"display": "number",
|
|
"tooltip": "Number of visible rails (2-10)"
|
|
}),
|
|
"selected_rail": ("INT", {
|
|
"default": 0,
|
|
"min": 0,
|
|
"max": 10,
|
|
"step": 1,
|
|
"display": "number",
|
|
"tooltip": "Active rail: 0=AUTO (first connected), 1-10=manual selection"
|
|
}),
|
|
"status": ("STRING", {
|
|
"default": "💡 Active Rail: auto",
|
|
"multiline": True,
|
|
"dynamicPrompts": False
|
|
}),
|
|
},
|
|
"optional": {
|
|
# Rail 1 and Rail 2 are PERMANENT - never removed
|
|
"model_1": ("MODEL",),
|
|
"clip_1": ("CLIP",),
|
|
"vae_1": ("VAE",),
|
|
"model_2": ("MODEL",),
|
|
"clip_2": ("CLIP",),
|
|
"vae_2": ("VAE",),
|
|
},
|
|
"hidden": {
|
|
"unique_id": "UNIQUE_ID",
|
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "VAE")
|
|
RETURN_NAMES = ("model", "clip", "vae")
|
|
FUNCTION = "switch_model"
|
|
CATEGORY = "AF - Nodes/AF - Prompt Pack"
|
|
OUTPUT_NODE = True
|
|
|
|
def switch_model(self, number_of_rails, selected_rail, status="", unique_id=None, extra_pnginfo=None, **kwargs):
|
|
# Clamp values to valid ranges
|
|
number_of_rails = max(2, min(10, number_of_rails))
|
|
selected_rail = max(0, min(number_of_rails, selected_rail))
|
|
|
|
# Determine actual active rail (1-based throughout!)
|
|
actual_rail = None
|
|
is_auto_mode = (selected_rail == 0)
|
|
|
|
if is_auto_mode:
|
|
# AUTO mode - find first rail with data
|
|
for i in range(1, number_of_rails + 1): # Rails 1 to number_of_rails
|
|
test_model = kwargs.get(f"model_{i}", None)
|
|
if test_model is not None:
|
|
actual_rail = i
|
|
print(f"AF Model Switch - AUTO → Rail {actual_rail}")
|
|
break
|
|
|
|
if actual_rail is None:
|
|
# No rails have data, default to rail 1
|
|
actual_rail = 1
|
|
print(f"AF Model Switch - AUTO → No data found, defaulting to Rail 1")
|
|
|
|
status_text = f"💡 Active Rail: auto → {actual_rail}"
|
|
else:
|
|
# Manual mode - user selected specific rail (1-based)
|
|
actual_rail = selected_rail
|
|
status_text = f"🎯 Active Rail: {actual_rail}"
|
|
print(f"AF Model Switch - Manual → Rail {actual_rail}")
|
|
|
|
# Get selected components using 1-based rail number directly
|
|
model = kwargs.get(f"model_{actual_rail}", None)
|
|
clip = kwargs.get(f"clip_{actual_rail}", None)
|
|
vae = kwargs.get(f"vae_{actual_rail}", None)
|
|
|
|
# Final fallback if still no model
|
|
if model is None:
|
|
for i in range(1, number_of_rails + 1):
|
|
test_model = kwargs.get(f"model_{i}", None)
|
|
if test_model is not None:
|
|
model = test_model
|
|
clip = kwargs.get(f"clip_{i}", None)
|
|
vae = kwargs.get(f"vae_{i}", None)
|
|
print(f"AF Model Switch - Fallback to rail {i}")
|
|
break
|
|
|
|
if model is not None:
|
|
print(f"AF Model Switch - ✓ Outputting Rail {actual_rail} (of 1-{number_of_rails})")
|
|
else:
|
|
print(f"AF Model Switch - ⚠️ No models connected")
|
|
status_text = "⚠️ Active Rail: none (no connections)"
|
|
|
|
return {
|
|
"ui": {"status": [status_text]},
|
|
"result": (model, clip, vae)
|
|
}
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, number_of_rails, selected_rail, **kwargs):
|
|
# Force re-evaluation when inputs change
|
|
return f"{number_of_rails}-{selected_rail}"
|
|
|
|
# Update NODE_CLASS_MAPPINGS
|
|
NODE_CLASS_MAPPINGS = {
|
|
"AF_Edit_Generated_Prompt": AF_Edit_Generated_Prompt,
|
|
"AF_Save_Prompt_History": AF_Save_Prompt_History,
|
|
"AF_Load_Prompt_History": AF_Load_Prompt_History,
|
|
"AF_Show_Text": AF_Show_Text,
|
|
"AF_Model_Switch": AF_Model_Switch,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"AF_Edit_Generated_Prompt": "AF - Edit Generated Prompt",
|
|
"AF_Save_Prompt_History": "AF - Save Prompt History",
|
|
"AF_Load_Prompt_History": "AF - Load Prompt History",
|
|
"AF_Show_Text": "AF - Show Text",
|
|
"AF_Model_Switch": "AF - Model Switch",
|
|
}
|