Merge pull request #91 from ComfyAssets/feature/button

Feature/button fixes #88
This commit is contained in:
Vito
2026-02-02 15:16:58 -08:00
committed by GitHub
14 changed files with 580 additions and 126 deletions
+28
View File
@@ -82,6 +82,34 @@ except Exception as e:
except:
pass
# Start image monitoring globally at module import time
# This ensures images are linked regardless of which node is used in the workflow
try:
from .database.operations import PromptDatabase
from .utils.image_monitor import get_image_monitor
from .utils.prompt_tracker import get_prompt_tracker
from .utils.logging_config import get_logger
_init_logger = get_logger("prompt_manager.init")
_init_logger.info("Starting global image monitoring system...")
# Initialize database and get singleton instances
_global_db = PromptDatabase()
_global_prompt_tracker = get_prompt_tracker(_global_db)
_global_image_monitor = get_image_monitor(_global_db, _global_prompt_tracker)
# Start monitoring
_global_image_monitor.start_monitoring()
_init_logger.info("Global image monitoring system started successfully")
except Exception as e:
try:
from .utils.logging_config import get_logger
_init_logger = get_logger("prompt_manager.init")
_init_logger.error(f"Failed to start image monitoring: {e}")
except:
print(f"[ComfyUI-PromptManager] Warning: Failed to start image monitoring: {e}")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# Print startup message with loaded tools
+96 -2
View File
@@ -91,9 +91,13 @@ class PromptModel:
workflow_data TEXT,
prompt_metadata TEXT,
parameters TEXT,
FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE,
UNIQUE(prompt_id, filename)
)
""")
# Add unique constraint to existing databases (migration)
self._migrate_add_unique_constraint(conn)
# Check if we need to migrate from old schema with workflow_name
self._migrate_workflow_name_removal(conn)
@@ -249,10 +253,100 @@ class PromptModel:
self.logger.error(f"Foreign key migration error: {e}")
# If migration fails, continue with existing schema
def _migrate_add_unique_constraint(self, conn: sqlite3.Connection) -> None:
"""
Add UNIQUE constraint on (prompt_id, filename) to prevent duplicate image entries.
This migration:
1. Checks if the constraint already exists
2. Removes duplicate entries (keeping the most recent)
3. Recreates the table with the UNIQUE constraint
Args:
conn: Active database connection
"""
try:
# Check if the unique constraint already exists by looking at table info
cursor = conn.execute("PRAGMA index_list(generated_images)")
indexes = cursor.fetchall()
# Check if we have a unique index on prompt_id, filename
has_unique_constraint = False
for idx in indexes:
if idx[2] == 1: # unique flag
cursor = conn.execute(f"PRAGMA index_info({idx[1]})")
columns = [col[2] for col in cursor.fetchall()]
if 'prompt_id' in columns and 'filename' in columns:
has_unique_constraint = True
break
if has_unique_constraint:
return # Already migrated
self.logger.info("Migrating database: adding UNIQUE constraint on (prompt_id, filename)")
# First, remove duplicates keeping only the most recent (highest id)
conn.execute("""
DELETE FROM generated_images
WHERE id NOT IN (
SELECT MAX(id) FROM generated_images
GROUP BY prompt_id, filename
)
""")
duplicates_removed = conn.total_changes
if duplicates_removed > 0:
self.logger.info(f"Removed {duplicates_removed} duplicate image entries")
# Create new table with UNIQUE constraint
conn.execute("""
CREATE TABLE IF NOT EXISTS generated_images_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt_id INTEGER NOT NULL,
image_path TEXT NOT NULL,
filename TEXT NOT NULL,
generation_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
file_size INTEGER,
width INTEGER,
height INTEGER,
format TEXT,
workflow_data TEXT,
prompt_metadata TEXT,
parameters TEXT,
FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE,
UNIQUE(prompt_id, filename)
)
""")
# Copy data
conn.execute("""
INSERT INTO generated_images_new
(id, prompt_id, image_path, filename, generation_time, file_size,
width, height, format, workflow_data, prompt_metadata, parameters)
SELECT id, prompt_id, image_path, filename, generation_time, file_size,
width, height, format, workflow_data, prompt_metadata, parameters
FROM generated_images
""")
# Drop old table and rename new one
conn.execute("DROP TABLE generated_images")
conn.execute("ALTER TABLE generated_images_new RENAME TO generated_images")
# Recreate indexes
conn.execute("CREATE INDEX IF NOT EXISTS idx_prompt_images ON generated_images(prompt_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_image_path ON generated_images(image_path)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_generation_time ON generated_images(generation_time)")
self.logger.info("UNIQUE constraint migration completed successfully")
except Exception as e:
self.logger.error(f"UNIQUE constraint migration error: {e}")
# Continue with existing schema if migration fails
def migrate_database(self) -> None:
"""
Apply any pending database migrations.
This method serves as an entry point for future schema migrations.
Add new migration logic here as the database evolves.
"""
+13 -7
View File
@@ -654,11 +654,12 @@ class PromptDatabase:
# Proceed with linking
filename = os.path.basename(image_path)
file_info = metadata.get('file_info', {}) if metadata else {}
# Use INSERT OR IGNORE to skip duplicates (same prompt_id + filename)
cursor = conn.execute(
"""
INSERT INTO generated_images
(prompt_id, image_path, filename, file_size, width, height, format,
INSERT OR IGNORE INTO generated_images
(prompt_id, image_path, filename, file_size, width, height, format,
workflow_data, prompt_metadata, parameters)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
@@ -676,6 +677,11 @@ class PromptDatabase:
)
)
conn.commit()
if cursor.lastrowid == 0:
self.logger.debug(f"Image {filename} already linked to prompt {prompt_id_int}")
return 0
return cursor.lastrowid
except Exception as e:
@@ -685,18 +691,18 @@ class PromptDatabase:
def get_prompt_images(self, prompt_id: str) -> List[Dict[str, Any]]:
"""
Get all images associated with a prompt.
Args:
prompt_id: The prompt ID
Returns:
List of image records
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"""
SELECT * FROM generated_images
WHERE prompt_id = ?
SELECT * FROM generated_images
WHERE prompt_id = ?
ORDER BY generation_time DESC
""",
(prompt_id,)
+21 -32
View File
@@ -37,8 +37,8 @@ except ImportError:
try:
from .database.operations import PromptDatabase
from .utils.comfyui_integration import get_comfyui_integration
from .utils.image_monitor import ImageMonitor
from .utils.prompt_tracker import PromptExecutionContext, PromptTracker
from .utils.image_monitor import get_image_monitor
from .utils.prompt_tracker import PromptExecutionContext, get_prompt_tracker
except ImportError:
# For direct imports when not in a package
import os
@@ -47,8 +47,8 @@ except ImportError:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from database.operations import PromptDatabase
from utils.comfyui_integration import get_comfyui_integration
from utils.image_monitor import ImageMonitor
from utils.prompt_tracker import PromptExecutionContext, PromptTracker
from utils.image_monitor import get_image_monitor
from utils.prompt_tracker import PromptExecutionContext, get_prompt_tracker
class PromptManager(ComfyNodeABC):
@@ -65,8 +65,9 @@ class PromptManager(ComfyNodeABC):
self.logger.debug("Initializing PromptManager node")
self.db = PromptDatabase()
self.prompt_tracker = PromptTracker(self.db)
self.image_monitor = ImageMonitor(self.db, self.prompt_tracker)
# Use singleton getters to ensure only one tracker/monitor exists
self.prompt_tracker = get_prompt_tracker(self.db)
self.image_monitor = get_image_monitor(self.db, self.prompt_tracker)
self.comfyui_integration = get_comfyui_integration()
# Start image monitoring automatically
@@ -132,7 +133,7 @@ class PromptManager(ComfyNodeABC):
"A conditioning containing the embedded text used to guide the diffusion model.",
"The final combined text string (with prepend/append applied) that was encoded.",
)
FUNCTION = "encode"
FUNCTION = "encode_prompt"
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = (
"Encodes a text prompt using a CLIP model into an embedding that can be used to guide "
@@ -140,7 +141,7 @@ class PromptManager(ComfyNodeABC):
"to a local SQLite database with optional metadata for search and retrieval."
)
def encode(
def encode_prompt(
self,
clip,
text: str,
@@ -168,11 +169,18 @@ class PromptManager(ComfyNodeABC):
Raises:
RuntimeError: If clip input is invalid
"""
# Debug: Log text being encoded - this should print EVERY time the node executes
import time
print(f"\n{'='*60}")
print(f"[PromptManager] encode_prompt() CALLED at {time.time()}")
print(f"[PromptManager] text = {repr(text)[:100]}")
print(f"{'='*60}\n")
# Combine prepend, main text, and append text
final_text = ""
if prepend_text and prepend_text.strip():
final_text += prepend_text.strip() + " "
final_text += text
final_text += text if text else ""
if append_text and append_text.strip():
final_text += " " + append_text.strip()
@@ -259,7 +267,7 @@ class PromptManager(ComfyNodeABC):
},
)
self.logger.debug("CLIP encoding completed successfully")
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
return (conditioning, encoding_text)
def _save_prompt_to_database(
@@ -484,25 +492,6 @@ class PromptManager(ComfyNodeABC):
"""
self.cleanup_gallery_system()
@classmethod
def IS_CHANGED(cls, text="", category="", tags="", search_text="",
prepend_text="", append_text="", **kwargs):
"""
ComfyUI method to determine if node needs re-execution.
This method now properly tracks input changes to avoid unnecessary
re-execution while still ensuring prompts are saved when inputs change.
Returns:
A hash of the input values that changes when any input changes
"""
# Create a hash of all the text inputs that affect the output
# This ensures the node only re-executes when inputs actually change
import hashlib
# Combine all text inputs that affect the conditioning output
combined = f"{text}|{category}|{tags}|{prepend_text}|{append_text}"
# Return a hash that will change when inputs change
# Note: We don't include search_text as it doesn't affect the output conditioning
return hashlib.sha256(combined.encode()).hexdigest()
# NOTE: IS_CHANGED intentionally removed to match CLIPTextEncode behavior
# ComfyUI's default caching (based on input values) should handle cache invalidation
# The previous IS_CHANGED implementation was causing input/cache mismatch issues
+10 -28
View File
@@ -37,8 +37,8 @@ except ImportError:
try:
from .database.operations import PromptDatabase
from .utils.comfyui_integration import get_comfyui_integration
from .utils.image_monitor import ImageMonitor
from .utils.prompt_tracker import PromptExecutionContext, PromptTracker
from .utils.image_monitor import get_image_monitor
from .utils.prompt_tracker import PromptExecutionContext, get_prompt_tracker
except ImportError:
# For direct imports when not in a package
import os
@@ -47,8 +47,8 @@ except ImportError:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from database.operations import PromptDatabase
from utils.comfyui_integration import get_comfyui_integration
from utils.image_monitor import ImageMonitor
from utils.prompt_tracker import PromptExecutionContext, PromptTracker
from utils.image_monitor import get_image_monitor
from utils.prompt_tracker import PromptExecutionContext, get_prompt_tracker
class PromptManagerText(ComfyNodeABC):
@@ -67,8 +67,9 @@ class PromptManagerText(ComfyNodeABC):
self.logger.debug("Initializing PromptManagerText node")
self.db = PromptDatabase()
self.prompt_tracker = PromptTracker(self.db)
self.image_monitor = ImageMonitor(self.db, self.prompt_tracker)
# Use singleton getters to ensure only one tracker/monitor exists
self.prompt_tracker = get_prompt_tracker(self.db)
self.image_monitor = get_image_monitor(self.db, self.prompt_tracker)
self.comfyui_integration = get_comfyui_integration()
# Start image monitoring automatically
@@ -455,25 +456,6 @@ class PromptManagerText(ComfyNodeABC):
"""
self.cleanup_gallery_system()
@classmethod
def IS_CHANGED(cls, text="", category="", tags="", search_text="",
prepend_text="", append_text="", **kwargs):
"""
ComfyUI method to determine if node needs re-execution.
This method now properly tracks input changes to avoid unnecessary
re-execution while still ensuring prompts are saved when inputs change.
Returns:
A hash of the input values that changes when any input changes
"""
# Create a hash of all the text inputs that affect the output
# This ensures the node only re-executes when inputs actually change
import hashlib
# Combine all text inputs that affect the output
combined = f"{text}|{category}|{tags}|{prepend_text}|{append_text}"
# Return a hash that will change when inputs change
# Note: We don't include search_text as it doesn't affect the output
return hashlib.sha256(combined.encode()).hexdigest()
# NOTE: IS_CHANGED intentionally removed to match CLIPTextEncode behavior
# ComfyUI's default caching (based on input values) should handle cache invalidation
# The previous IS_CHANGED implementation was causing input/cache mismatch issues
+83 -19
View File
@@ -302,6 +302,46 @@ class PromptManagerAPI:
status=500,
)
# Serve static files from web/lib directory
@routes.get("/prompt_manager/lib/{filepath:.*}")
async def serve_lib_static(request):
"""Serve static library files (JS, CSS) from web/lib directory."""
import os
# Explicit MIME type mapping
MIME_TYPES = {
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".map": "application/json",
}
filepath = request.match_info.get("filepath", "")
# Security: prevent directory traversal
if ".." in filepath or filepath.startswith("/"):
return web.Response(text="Forbidden", status=403)
current_dir = os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)
file_path = os.path.join(current_dir, "web", "lib", filepath)
if not os.path.exists(file_path) or not os.path.isfile(file_path):
return web.Response(text=f"Not Found: {filepath}", status=404)
# Get extension and content type
ext = os.path.splitext(file_path)[1].lower()
content_type = MIME_TYPES.get(ext, "application/octet-stream")
with open(file_path, "rb") as f:
content = f.read()
return web.Response(
body=content,
content_type=content_type,
)
# Statistics endpoint
@routes.get("/prompt_manager/stats")
async def get_stats_route(request):
@@ -1021,10 +1061,11 @@ class PromptManagerAPI:
thumbnails_dir = output_path / "thumbnails"
if thumbnails_dir.exists():
thumbnail_ext = '.jpg' if is_video else extension
# Use full relative path to support subdirectories
thumbnail_rel_path = Path("thumbnails") / rel_path.parent / f"{media_path.stem}_thumb{thumbnail_ext}"
# Preserve subdirectory structure in thumbnail path
rel_path_no_ext = rel_path.with_suffix('')
thumbnail_rel_path = f"thumbnails/{rel_path_no_ext.as_posix()}_thumb{thumbnail_ext}"
thumbnail_abs_path = output_path / thumbnail_rel_path
if thumbnail_abs_path.exists():
from urllib.parse import quote
thumbnail_url = f'/prompt_manager/images/serve/{quote(thumbnail_rel_path.as_posix(), safe="/")}'
@@ -1148,9 +1189,9 @@ class PromptManagerAPI:
# Also try to delete associated thumbnail if it exists
try:
# Use full relative path to support subdirectories
rel_path = file_path.relative_to(output_path)
thumbnail_path = output_path / "thumbnails" / rel_path.parent / f"{file_path.stem}_thumb{file_path.suffix}"
rel_path_no_ext = rel_path.with_suffix('')
thumbnail_path = output_path / "thumbnails" / f"{rel_path_no_ext.as_posix()}_thumb{file_path.suffix}"
if thumbnail_path.exists():
os.remove(thumbnail_path)
self.logger.debug(f"Deleted associated thumbnail: {thumbnail_path}")
@@ -1912,9 +1953,11 @@ class PromptManagerAPI:
# For videos, look for thumbnail with .jpg extension
# Use full relative path to support subdirectories
thumbnail_ext = '.jpg' if is_video else extension
thumbnail_rel_path = Path("thumbnails") / rel_path.parent / f"{media_path.stem}_thumb{thumbnail_ext}"
# Preserve subdirectory structure in thumbnail path
rel_path_no_ext = rel_path.with_suffix('')
thumbnail_rel_path = f"thumbnails/{rel_path_no_ext.as_posix()}_thumb{thumbnail_ext}"
thumbnail_abs_path = output_path / thumbnail_rel_path
if thumbnail_abs_path.exists():
from urllib.parse import quote
thumbnail_url = f'/prompt_manager/images/serve/{quote(thumbnail_rel_path.as_posix(), safe="/")}'
@@ -2165,11 +2208,12 @@ class PromptManagerAPI:
is_video = any(media_file.name.lower().endswith(ext) for ext in video_extensions)
# For videos, always save thumbnail as .jpg
# Include parent directory structure to avoid collisions with same-named files
# Preserve subdirectory structure in thumbnail path
rel_path_no_ext = rel_path.with_suffix('')
if is_video:
thumbnail_path = thumbnails_dir / rel_path.parent / f"{rel_path.stem}_thumb.jpg"
thumbnail_path = thumbnails_dir / f"{rel_path_no_ext.as_posix()}_thumb.jpg"
else:
thumbnail_path = thumbnails_dir / rel_path.parent / f"{rel_path.stem}_thumb{rel_path.suffix}"
thumbnail_path = thumbnails_dir / f"{rel_path_no_ext.as_posix()}_thumb{rel_path.suffix}"
# Skip if thumbnail already exists and is newer than original
if (thumbnail_path.exists() and
@@ -2382,11 +2426,12 @@ class PromptManagerAPI:
rel_path = media_file.relative_to(output_path)
# For videos, always save thumbnail as .jpg
# Include parent directory structure to avoid collisions with same-named files
# Preserve subdirectory structure in thumbnail path
rel_path_no_ext = rel_path.with_suffix('')
if is_video:
thumbnail_path = thumbnails_dir / rel_path.parent / f"{rel_path.stem}_thumb.jpg"
thumbnail_path = thumbnails_dir / f"{rel_path_no_ext.as_posix()}_thumb.jpg"
else:
thumbnail_path = thumbnails_dir / rel_path.parent / f"{rel_path.stem}_thumb{rel_path.suffix}"
thumbnail_path = thumbnails_dir / f"{rel_path_no_ext.as_posix()}_thumb{rel_path.suffix}"
# SAFETY: Ensure thumbnail path is within our thumbnails directory
try:
@@ -3002,7 +3047,27 @@ class PromptManagerAPI:
'status': 'ok' if output_dirs else 'warning',
'output_dirs': output_dirs
}
# Check image monitor status
try:
from ..utils.image_monitor import _monitor_instance
if _monitor_instance is not None:
monitor_status = _monitor_instance.get_status()
results['image_monitor'] = {
'status': 'ok' if monitor_status.get('observer_alive') else 'error',
**monitor_status
}
else:
results['image_monitor'] = {
'status': 'error',
'message': 'Image monitor not initialized'
}
except Exception as e:
results['image_monitor'] = {
'status': 'error',
'message': f'Failed to get monitor status: {str(e)}'
}
return web.json_response({
'success': True,
'diagnostics': results
@@ -4747,12 +4812,11 @@ class PromptManagerAPI:
thumbnail_url = None
thumbnails_dir = output_path / "thumbnails"
if thumbnails_dir.exists():
# Use full relative path to support subdirectories
thumbnail_rel = Path("thumbnails") / rel_path.parent / f"{image_path.stem}_thumb{image_path.suffix}"
thumbnail_path = output_path / thumbnail_rel
# Preserve subdirectory structure in thumbnail path
rel_path_no_ext = rel_path.with_suffix('')
thumbnail_path = thumbnails_dir / f"{rel_path_no_ext.as_posix()}_thumb{image_path.suffix}"
if thumbnail_path.exists():
from urllib.parse import quote
thumbnail_url = f'/prompt_manager/images/serve/{quote(thumbnail_rel.as_posix(), safe="/")}'
thumbnail_url = f'/prompt_manager/images/serve/thumbnails/{rel_path_no_ext.as_posix()}_thumb{image_path.suffix}'
images.append({
'filename': image_path.name,
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.0.26"
version = "3.0.27"
license = {file = "LICENSE"}
dependencies = ["# Core dependencies for PromptManager", "# Note: Most dependencies are already included with ComfyUI", "# Already included with Python standard library:", "# - sqlite3", "# - hashlib", "# - json", "# - datetime", "# - os", "# - typing", "# - threading", "# - uuid", "# Required for gallery functionality:", "watchdog>=2.1.0 # For file system monitoring", "Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)", "# Optional dependencies for enhanced search functionality:", "# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)", "# sqlalchemy>=1.4.0 # For advanced ORM features (optional)", "# Development dependencies (optional):", "# pytest>=6.0.0 # For running tests", "# black>=22.0.0 # For code formatting", "# flake8>=4.0.0 # For linting", "# mypy>=0.910 # For type checking"]
+8 -8
View File
@@ -30,16 +30,16 @@ sys.path.insert(0, current_dir)
try:
# Import and initialize the gallery system
from database.operations import PromptDatabase
from utils.prompt_tracker import PromptTracker
from utils.image_monitor import ImageMonitor
from utils.prompt_tracker import get_prompt_tracker
from utils.image_monitor import get_image_monitor
print("[RESTART] Restarting PromptManager Gallery System...")
# Initialize components
# Initialize components using singleton getters
db = PromptDatabase()
tracker = PromptTracker(db)
monitor = ImageMonitor(db, tracker)
tracker = get_prompt_tracker(db)
monitor = get_image_monitor(db, tracker)
print("[SUCCESS] Components initialized successfully")
# Test image monitoring directories
+47 -13
View File
@@ -75,11 +75,11 @@ class ImageGenerationHandler(FileSystemEventHandler):
event: FileSystemEvent object containing event details
"""
if not event.is_directory and self.is_image_file(event.src_path):
self.logger.debug(f"New image detected: {event.src_path}")
self.logger.info(f"New image detected: {event.src_path}")
# Small delay to ensure file is fully written
threading.Timer(
self.processing_delay,
self.process_new_image,
self.processing_delay,
self.process_new_image,
args=[event.src_path]
).start()
@@ -108,15 +108,15 @@ class ImageGenerationHandler(FileSystemEventHandler):
image_path: Full path to the newly created image file
"""
try:
self.logger.debug(f"Processing image: {image_path}")
self.logger.info(f"Processing image: {image_path}")
if not os.path.exists(image_path):
self.logger.warning(f"Image file no longer exists: {image_path}")
return
# Get current prompt context first
current_prompt = self.prompt_tracker.get_current_prompt()
self.logger.debug(f"Current prompt context: {current_prompt['id'] if current_prompt else 'None'}")
self.logger.info(f"Current prompt context: {current_prompt['id'] if current_prompt else 'None'}")
if not current_prompt:
self.logger.debug(f"No active prompt context for image: {image_path}")
@@ -301,13 +301,13 @@ class ImageMonitor:
if os.path.exists(output_dir):
self.observer.schedule(self.handler, output_dir, recursive=True)
self.monitored_directories.append(output_dir)
self.logger.debug(f"Monitoring directory: {output_dir}")
self.logger.info(f"Monitoring directory (recursive): {output_dir}")
else:
self.logger.warning(f"Directory does not exist: {output_dir}")
if self.monitored_directories:
self.observer.start()
self.logger.debug(f"Image monitoring started for {len(self.monitored_directories)} directories")
self.logger.info(f"Image monitoring started for {len(self.monitored_directories)} directories")
else:
self.logger.warning("No valid directories to monitor")
@@ -367,15 +367,49 @@ class ImageMonitor:
def get_status(self) -> Dict[str, Any]:
"""Get monitoring status information.
Returns:
Dictionary containing:
- running: Boolean indicating if monitoring is active
- monitored_directories: List of currently monitored directory paths
- handler_active: Boolean indicating if the event handler is active
- observer_alive: Boolean indicating if observer thread is alive
"""
observer_alive = False
if self.observer is not None:
try:
observer_alive = self.observer.is_alive()
except Exception:
pass
return {
'running': self.observer is not None,
'monitored_directories': self.monitored_directories,
'handler_active': self.handler is not None
}
'handler_active': self.handler is not None,
'observer_alive': observer_alive
}
# Singleton instance management
_monitor_instance: Optional[ImageMonitor] = None
_monitor_lock = threading.Lock()
def get_image_monitor(db_manager, prompt_tracker) -> ImageMonitor:
"""Get or create the singleton ImageMonitor instance.
This ensures only one ImageMonitor exists across all PromptManager nodes,
preventing duplicate image detection when multiple nodes are used.
Args:
db_manager: Database manager instance for storing image relationships
prompt_tracker: Prompt tracking instance for getting active prompt context
Returns:
The singleton ImageMonitor instance
"""
global _monitor_instance
if _monitor_instance is None:
with _monitor_lock:
if _monitor_instance is None:
_monitor_instance = ImageMonitor(db_manager, prompt_tracker)
return _monitor_instance
+29 -4
View File
@@ -408,16 +408,41 @@ class PromptExecutionContext:
def __exit__(self, exc_type, exc_val, exc_tb):
"""Exit the execution context.
Currently does not clear the prompt context to allow images to be
generated after prompt execution completes. The prompt will be cleaned
up automatically after the timeout period.
Args:
exc_type: Exception type (if an exception occurred)
exc_val: Exception value (if an exception occurred)
exc_val: Exception value (if an exception occurred)
exc_tb: Exception traceback (if an exception occurred)
"""
# Don't clear immediately - let the timeout handle it
# This allows images to be generated after the prompt execution completes
pass
pass
# Singleton instance management
_tracker_instance: Optional[PromptTracker] = None
_tracker_lock = threading.Lock()
def get_prompt_tracker(db_manager) -> PromptTracker:
"""Get or create the singleton PromptTracker instance.
This ensures only one PromptTracker exists across all PromptManager nodes,
preventing duplicate image linking when multiple nodes are used.
Args:
db_manager: Database manager instance for prompt operations
Returns:
The singleton PromptTracker instance
"""
global _tracker_instance
if _tracker_instance is None:
with _tracker_lock:
if _tracker_instance is None:
_tracker_instance = PromptTracker(db_manager)
return _tracker_instance
+3 -3
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromptManager - Admin Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.js"></script>
<script src="lib/tailwind/tailwind.js"></script>
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
<script src="lib/viewerjs/viewer.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
+3 -3
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromptManager - Gallery</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.js"></script>
<script src="lib/tailwind/tailwind.js"></script>
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
<script src="lib/viewerjs/viewer.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Gallery - Generation Data</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="lib/tailwind/tailwind.js"></script>
<style>
/* Ensure the layout takes full height and prevents overflow */
html, body {
+237 -5
View File
@@ -1,6 +1,7 @@
// PromptManager/web/prompt_manager.js
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ============================================================================
// Top Bar Button Integration (similar to rgthree-comfy approach)
@@ -158,6 +159,47 @@ function addPMTopBarButtonLegacy() {
// Extension Registration
// ============================================================================
/**
* CRITICAL FIX: Sync all PromptManager widget values from DOM
* This must be called before any serialization happens
*/
function syncPromptManagerWidgets() {
const graph = app.graph;
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if (node.type === "PromptManager" || node.type === "PromptManagerText") {
if (node.widgets && node.widgets.length > 0) {
if (!node.widgets_values) {
node.widgets_values = [];
}
node.widgets.forEach((widget, idx) => {
if (widget) {
// CRITICAL: For multiline widgets, inputEl.value is the true source
const actualValue = widget.inputEl ? widget.inputEl.value : widget.value;
if (actualValue !== undefined) {
const oldValue = node.widgets_values[idx];
node.widgets_values[idx] = actualValue;
// Also sync widget.value
if (widget.inputEl && widget.value !== actualValue) {
widget.value = actualValue;
}
if (widget.name === "text" && oldValue !== actualValue) {
console.log(`[PromptManager] graphToPrompt sync: "${String(oldValue).substring(0, 30)}..." -> "${String(actualValue).substring(0, 30)}..."`);
}
}
}
});
}
}
}
}
app.registerExtension({
name: "PromptManager.UI",
@@ -166,6 +208,56 @@ app.registerExtension({
setTimeout(() => {
addPMTopBarButton();
}, 100);
// CRITICAL: Wrap app.graphToPrompt to sync widget values BEFORE serialization
// This runs before any other extension's wrapper can access stale data
const originalGraphToPrompt = app.graphToPrompt.bind(app);
app.graphToPrompt = async function(...args) {
console.log("[PromptManager] graphToPrompt intercepted - syncing widgets NOW");
syncPromptManagerWidgets();
return originalGraphToPrompt(...args);
};
console.log("[PromptManager] Wrapped app.graphToPrompt for value sync");
// CRITICAL FIX: Intercept api.queuePrompt to fix prompt data at the LAST moment
// This catches any cached/stale data that other extensions might have passed through
const originalQueuePrompt = api.queuePrompt.bind(api);
api.queuePrompt = async function(number, { output, workflow }) {
console.log("[PromptManager] api.queuePrompt intercepted - fixing prompt data");
// Find PromptManager nodes in the output and fix their text values
const graph = app.graph;
if (graph && output) {
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if ((node.type === "PromptManager" || node.type === "PromptManagerText") && output[node.id]) {
// Find the text widget and get its current DOM value
const textWidget = node.widgets?.find(w => w.name === "text");
if (textWidget && textWidget.inputEl) {
const currentValue = textWidget.inputEl.value;
const outputNode = output[node.id];
// The output structure has "inputs" with widget values
if (outputNode.inputs && outputNode.inputs.text !== currentValue) {
console.log(`[PromptManager] FIXING node ${node.id} text: "${String(outputNode.inputs.text).substring(0, 30)}..." -> "${currentValue.substring(0, 30)}..."`);
outputNode.inputs.text = currentValue;
}
}
}
}
}
return originalQueuePrompt(number, { output, workflow });
};
console.log("[PromptManager] Wrapped api.queuePrompt for final data fix");
},
/**
* CRITICAL: Also hook beforeQueuePrompt as a backup sync point
*/
async beforeQueuePrompt(promptInfo) {
console.log("[PromptManager] beforeQueuePrompt - additional sync");
syncPromptManagerWidgets();
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
@@ -181,6 +273,62 @@ app.registerExtension({
onNodeCreated.apply(this, arguments);
}
// CRITICAL FIX: Ensure text widget always returns current value during serialization
// This fixes the issue where graphToPrompt reads stale cached values
const self = this;
setTimeout(() => {
const textWidget = self.widgets?.find((w) => w.name === "text");
if (textWidget) {
console.log("[PromptManager] Patching text widget for proper serialization");
// Store reference to the widget for the closure
const widgetRef = textWidget;
const originalSerializeValue = textWidget.serializeValue;
// Override serializeValue - use arrow function to capture widgetRef
textWidget.serializeValue = (nodeId, widgetIndex) => {
// Always return the current DOM value using our stored reference
if (widgetRef.inputEl) {
const currentValue = widgetRef.inputEl.value;
// Sync widget.value as well
widgetRef.value = currentValue;
console.log(`[PromptManager] serializeValue returning: "${currentValue.substring(0, 50)}..."`);
return currentValue;
}
// Fallback to widget.value
console.log(`[PromptManager] serializeValue fallback to widget.value: "${String(widgetRef.value).substring(0, 50)}..."`);
return widgetRef.value;
};
// Add aggressive sync listeners for all value change events
if (textWidget.inputEl) {
const syncValue = function() {
const currentVal = textWidget.inputEl.value;
textWidget.value = currentVal;
// Also update widgets_values array
const widgetIndex = self.widgets?.indexOf(textWidget);
if (self.widgets_values && widgetIndex >= 0) {
self.widgets_values[widgetIndex] = currentVal;
}
};
// Sync on every keystroke
textWidget.inputEl.addEventListener('input', syncValue);
// Sync on change (for paste, autocomplete, etc.)
textWidget.inputEl.addEventListener('change', syncValue);
// Sync when focus leaves the field
textWidget.inputEl.addEventListener('blur', syncValue);
// Sync on keyup as backup
textWidget.inputEl.addEventListener('keyup', syncValue);
// Initial sync
syncValue();
console.log("[PromptManager] Added comprehensive input listeners to text widget");
}
}
}, 50); // Shorter delay to beat other extensions
// Initialize properties for search state (non-serialized runtime state)
this.properties = this.properties || {};
this.properties.resultTimeout = 3; // Default 3 seconds
@@ -261,12 +409,40 @@ app.registerExtension({
}
};
// Hook into serialization to preserve resize flag and prevent runtime data from being saved
// Hook into serialization to preserve resize flag, sync widget values, and prevent runtime data from being saved
const originalSerialize = this.serialize;
this.serialize = function () {
// CRITICAL: Sync widget values BEFORE serialization
// This ensures graphToPrompt reads current values, not cached ones
// For multiline widgets, the value is stored in inputEl.value
if (this.widgets && this.widgets.length > 0) {
if (!this.widgets_values) {
this.widgets_values = [];
}
this.widgets.forEach((widget, idx) => {
if (widget) {
// Get the actual value - inputEl.value for DOM widgets, otherwise widget.value
const actualValue = widget.inputEl ? widget.inputEl.value : widget.value;
if (actualValue !== undefined) {
this.widgets_values[idx] = actualValue;
// Sync widget.value with inputEl.value
if (widget.inputEl && widget.value !== actualValue) {
widget.value = actualValue;
}
}
}
});
console.log(`[PromptManager] serialize: Synced ${this.widgets.length} widget values`);
}
const data = originalSerialize ? originalSerialize.call(this) : {};
data._userHasResized = this._userHasResized;
// Ensure widgets_values is included in serialization
if (this.widgets_values) {
data.widgets_values = [...this.widgets_values];
}
// Ensure search results are never saved to workflow
if (data.properties && data.properties.searchResults) {
delete data.properties.searchResults;
@@ -274,7 +450,7 @@ app.registerExtension({
if (data.properties && data.properties.selectedPromptIndex) {
delete data.properties.selectedPromptIndex;
}
return data;
};
@@ -677,32 +853,88 @@ app.registerExtension({
// Find the text widget and set its value
const textWidget = this.widgets?.find((w) => w.name === "text");
if (textWidget) {
const textWidgetIndex = this.widgets.indexOf(textWidget);
console.log(`[PromptManager] DEBUG: Setting prompt on node ${this.id}`);
console.log(`[PromptManager] DEBUG: textWidget found at index ${textWidgetIndex}`);
console.log(`[PromptManager] DEBUG: Old value: ${textWidget.value?.substring(0, 50)}...`);
console.log(`[PromptManager] DEBUG: New value: ${prompt.text.substring(0, 50)}...`);
// Set the widget value
textWidget.value = prompt.text;
// CRITICAL: For multiline widgets, the value is stored in the DOM element (inputEl)
// graphToPrompt calls serializeValue() which reads from inputEl.value
if (textWidget.inputEl) {
textWidget.inputEl.value = prompt.text;
console.log(`[PromptManager] DEBUG: Updated inputEl.value directly`);
}
// CRITICAL: Also update widgets_values array if it exists
// ComfyUI's graphToPrompt reads from this array for execution
if (this.widgets_values && textWidgetIndex >= 0) {
this.widgets_values[textWidgetIndex] = prompt.text;
console.log(`[PromptManager] DEBUG: Updated widgets_values[${textWidgetIndex}]`);
}
// Trigger widget callback to notify ComfyUI of value change
if (textWidget.callback) {
textWidget.callback(prompt.text, app.canvas, this, null, null);
console.log(`[PromptManager] DEBUG: Called textWidget.callback`);
}
// Also set metadata if available
const categoryWidget = this.widgets?.find(
(w) => w.name === "category",
);
if (categoryWidget && prompt.category) {
const categoryIdx = this.widgets.indexOf(categoryWidget);
categoryWidget.value = prompt.category;
if (categoryWidget.inputEl) {
categoryWidget.inputEl.value = prompt.category;
}
if (this.widgets_values && categoryIdx >= 0) {
this.widgets_values[categoryIdx] = prompt.category;
}
if (categoryWidget.callback) {
categoryWidget.callback(prompt.category, app.canvas, this, null, null);
}
}
const tagsWidget = this.widgets?.find((w) => w.name === "tags");
if (tagsWidget && prompt.tags) {
tagsWidget.value = Array.isArray(prompt.tags)
const tagsIdx = this.widgets.indexOf(tagsWidget);
const tagsValue = Array.isArray(prompt.tags)
? prompt.tags.join(", ")
: prompt.tags;
tagsWidget.value = tagsValue;
if (tagsWidget.inputEl) {
tagsWidget.inputEl.value = tagsValue;
}
if (this.widgets_values && tagsIdx >= 0) {
this.widgets_values[tagsIdx] = tagsValue;
}
if (tagsWidget.callback) {
tagsWidget.callback(tagsValue, app.canvas, this, null, null);
}
}
// Visual feedback
this.highlightSelectedResult(index);
// Trigger widget change events
// Trigger widget change events and mark graph as needing re-execution
this.setDirtyCanvas(true, true);
// Force graph to recognize value change for execution cache invalidation
if (app.graph) {
app.graph.setDirtyCanvas(true, true);
}
console.log(
`[PromptManager] Loaded prompt: ${prompt.text.substring(0, 50)}...`,
);
console.log(`[PromptManager] DEBUG: Final widget value: ${textWidget.value?.substring(0, 50)}...`);
console.log(`[PromptManager] DEBUG: widgets_values: ${JSON.stringify(this.widgets_values?.slice(0, 3))}...`);
}
};