Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a17360c09 | ||
|
|
b376147dc1 | ||
|
|
6c3db41d76 | ||
|
|
9d2878f06d | ||
|
|
6c246fbd52 | ||
|
|
2cd3154764 | ||
|
|
051fa9ab68 | ||
|
|
a02c79418c | ||
|
|
1157714b80 | ||
|
|
3ee4a8b6a5 | ||
|
|
50f04a6223 | ||
|
|
195cf2adb4 | ||
|
|
b24343dc6c | ||
|
|
0e41e6d65c | ||
|
|
451596fab3 | ||
|
|
890fd70ac5 | ||
|
|
7713cecba6 | ||
|
|
fea745deb9 | ||
|
|
b71e4780e9 | ||
|
|
8c93acb2ce | ||
|
|
d42fe477c3 | ||
|
|
553d5c8dba | ||
|
|
bff70b6323 | ||
|
|
1a6cad42cb | ||
|
|
a2061e2977 | ||
|
|
062f430852 | ||
|
|
171a571a0d | ||
|
|
c0bcce5af1 | ||
|
|
4930d1fae7 | ||
|
|
582bb052f7 |
@@ -9,6 +9,7 @@ AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Local files
|
||||
data/
|
||||
docs/
|
||||
logs/
|
||||
standalone_tagger.py
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||

|
||||

|
||||
|
||||
A comprehensive ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, automatic image gallery system, and powerful ComfyUI workflow metadata analysis using SQLite.
|
||||
|
||||
## 📋 A Note on v2 Development
|
||||
|
||||
V2 development will take longer as life has taken me in other directions for the moment. I will still work on it but not as actively as before. In the meantime, I will backport some of the features users have requested from v2 over to v1. Once v2 is ready, I will update here. Thank you for your continued support!
|
||||
|
||||
---
|
||||
A comprehensive ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, automatic image gallery system, folder-based organization, LoRA Manager integration, and powerful ComfyUI workflow metadata analysis using SQLite.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -54,6 +48,7 @@ Both nodes include the complete PromptManager feature set:
|
||||
|
||||
- **💾 Persistent Storage**: Automatically saves all prompts to a local SQLite database
|
||||
- **🔍 Advanced Search**: Query past prompts with text search, category filtering, and metadata
|
||||
- **📁 Folder Filter**: Browse and filter prompts by output subdirectory
|
||||
- **🖼️ Automatic Image Gallery**: Automatically links generated images to their prompts
|
||||
- **🏷️ Rich Metadata**: Add categories, tags, ratings, notes, and workflow names to prompts
|
||||
- **🚫 Duplicate Prevention**: Uses SHA256 hashing to detect and prevent duplicate storage
|
||||
@@ -62,7 +57,8 @@ Both nodes include the complete PromptManager feature set:
|
||||
- **🔬 Workflow Analysis**: Extract and analyze ComfyUI workflow data from PNG images
|
||||
- **📋 Metadata Viewer**: Standalone tool for analyzing ComfyUI-generated images
|
||||
- **🛠️ System Management**: Built-in diagnostics, backup/restore, and maintenance tools
|
||||
- **🏷️ AI AutoTag**: Automatically tag your image collection using JoyCaption vision models
|
||||
- **🏷️ AI AutoTag**: Automatically tag your image collection using WD14 or JoyCaption vision models
|
||||
- **🔗 LoRA Manager Integration**: Import LoRA metadata and preview images from [ComfyUI-Lora-Manager](https://github.com/willchil/ComfyUI-Lora-Manager)
|
||||
|
||||

|
||||
|
||||
@@ -372,10 +368,11 @@ Import existing ComfyUI images into your database:
|
||||
|
||||
### 🏷️ AI AutoTag
|
||||
|
||||
Automatically tag your entire image collection using JoyCaption vision models:
|
||||
Automatically tag your entire image collection using AI vision models:
|
||||
|
||||
#### **Model Options**
|
||||
|
||||
- **WD14 SwinV2 / WD14 ViT**: Fast ONNX-based classifiers (~400MB) that output Danbooru tags with confidence scores — no prompt needed, adjustable confidence thresholds
|
||||
- **JoyCaption Beta One FP16**: Full precision model for highest quality tagging (requires more VRAM)
|
||||
- **JoyCaption Beta One GGUF (FP8)**: Quantized model for lower VRAM usage with minimal quality loss
|
||||
|
||||
@@ -417,6 +414,61 @@ Adjust the system prompt to match your tagging style:
|
||||
- Models are downloaded automatically on first use
|
||||
- Feature requests and improvements are welcome!
|
||||
|
||||
### 📁 Folder Filter
|
||||
|
||||

|
||||
|
||||
Organize and filter your prompt library by output subdirectory. The dashboard search panel includes a **Folder** dropdown that lists all subdirectories where your images are stored.
|
||||
|
||||
- **Automatic Detection**: Folders are extracted from your image paths — no configuration needed
|
||||
- **Multi-Directory Support**: Works across all configured gallery scan directories
|
||||
- **Quick Filtering**: Select a folder from the dropdown to instantly filter prompts to that subdirectory
|
||||
|
||||
> **Note:** If you have an existing prompt library from before the folder feature was added, you will need to **rescan your image library** (click **Scan Images** in the admin dashboard) to populate folder data for your existing prompts.
|
||||
|
||||
### 🔗 LoRA Manager Integration
|
||||
|
||||

|
||||
|
||||
If you use [ComfyUI-Lora-Manager](https://github.com/willchil/ComfyUI-Lora-Manager), PromptManager can import your LoRA metadata, trigger words, and example images directly into your prompt database. This lets you search, tag, and browse your LoRA collection alongside your regular prompts.
|
||||
|
||||
> **WIP:** LoRA Manager support is a work in progress — this was a highly requested feature. Please [open issues](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues) for any bugs or feature requests.
|
||||
|
||||
#### **Enabling the Integration**
|
||||
|
||||
1. Open the admin dashboard and click **Settings**
|
||||
2. Scroll to the **Integrations** section — PromptManager will auto-detect if LoRA Manager is installed
|
||||
|
||||

|
||||
|
||||
3. Toggle the **LoraManager** switch to enable
|
||||
4. Optionally enable **Auto-inject trigger words** to automatically append LoRA trigger words when `<lora:name:weight>` is detected in your prompts
|
||||
5. Click **Save Settings** (requires a ComfyUI restart to take effect)
|
||||
|
||||
#### **Importing LoRA Data**
|
||||
|
||||
Click **Import LoRA Data** in the settings panel to start the import. A progress popup shows real-time status as each LoRA is processed:
|
||||
|
||||
- Scans all LoRA directories (including `extra_model_paths.yaml` paths) for metadata
|
||||
- Downloads example images from CivitAI as 512px thumbnails
|
||||
- Creates a prompt entry for each LoRA with trigger words, example prompts, and preview images
|
||||
- All imported LoRAs are tagged with `lora-manager` category for easy filtering
|
||||
|
||||

|
||||
|
||||
After import, filter by the `lora-manager` category to browse your LoRA collection.
|
||||
|
||||
#### **CivitAI API Key (Optional)**
|
||||
|
||||
A CivitAI API key is **required if your library contains NSFW LoRAs** — CivitAI blocks unauthenticated access to NSFW preview images. Without a key, only SFW preview images are downloaded.
|
||||
|
||||
To add your key:
|
||||
1. Get your API key from [civitai.com/user/account](https://civitai.com/user/account)
|
||||
2. Paste it into the **CivitAI API Key** field in the Integrations settings
|
||||
3. Save and re-import to download any previously skipped images
|
||||
|
||||
> **Note:** Re-importing is safe — PromptManager skips LoRAs that were already imported. To do a fresh import, the previous `lora-manager` data is cleared automatically before re-scanning.
|
||||
|
||||
### 🌐 Web Interface Features
|
||||
|
||||
The comprehensive web interface provides:
|
||||
@@ -600,8 +652,9 @@ CREATE TABLE generated_images (
|
||||
- **`prompt_search_list.py`** - Batch search node implementation (LIST output)
|
||||
- **`database/models.py`** - Database schema and connection management
|
||||
- **`database/operations.py`** - CRUD operations and search functionality
|
||||
- **`py/api.py`** - Web API endpoints for the interface
|
||||
- **`py/api/`** - Web API route modules (prompts, images, tags, lora integration, etc.)
|
||||
- **`py/config.py`** - Configuration management
|
||||
- **`py/lora_utils.py`** - LoRA Manager integration utilities
|
||||
- **`utils/hashing.py`** - SHA256 hashing for deduplication
|
||||
- **`utils/validators.py`** - Input validation and sanitization
|
||||
- **`utils/image_monitor.py`** - Automatic image detection system
|
||||
@@ -611,7 +664,8 @@ CREATE TABLE generated_images (
|
||||
- **`utils/diagnostics.py`** - System diagnostics and health checks
|
||||
- **`web/admin.html`** - Advanced admin dashboard with metadata panel
|
||||
- **`web/index.html`** - Simple web interface
|
||||
- **`web/prompt_manager.js`** - JavaScript functionality
|
||||
- **`web/js/prompt_manager.js`** - Dashboard JavaScript
|
||||
- **`web/js/tags-page.js`** - Tag management JavaScript
|
||||
- **`web/metadata.html`** - Standalone PNG metadata viewer
|
||||
|
||||
### File Structure
|
||||
@@ -628,8 +682,9 @@ ComfyUI_PromptManager/
|
||||
│ └── operations.py # Database operations
|
||||
├── py/
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py # Web API endpoints
|
||||
│ └── config.py # Configuration
|
||||
│ ├── api/ # Web API route modules
|
||||
│ ├── config.py # Configuration
|
||||
│ └── lora_utils.py # LoRA Manager integration
|
||||
├── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── hashing.py # Hashing utilities
|
||||
@@ -641,9 +696,12 @@ ComfyUI_PromptManager/
|
||||
│ └── diagnostics.py # System diagnostics
|
||||
├── web/
|
||||
│ ├── admin.html # Advanced admin dashboard
|
||||
│ ├── gallery.html # Image gallery
|
||||
│ ├── index.html # Simple web interface
|
||||
│ ├── metadata.html # Standalone metadata viewer
|
||||
│ └── prompt_manager.js # JavaScript functionality
|
||||
│ └── js/
|
||||
│ ├── prompt_manager.js # Dashboard JavaScript
|
||||
│ └── tags-page.js # Tag management JavaScript
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ └── test_basic.py # Test suite
|
||||
@@ -752,7 +810,7 @@ db.model.backup_database("backup_prompts.db")
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd KikoTextEncode
|
||||
cd ComfyUI_PromptManager
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
@@ -797,7 +855,7 @@ The project follows PEP 8 guidelines with:
|
||||
For debugging, you can enable verbose logging in the node:
|
||||
|
||||
```python
|
||||
# Add to kiko_text_encode.py
|
||||
# Add to prompt_manager.py
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
```
|
||||
@@ -814,15 +872,15 @@ MIT License - see LICENSE file for details.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Completed in v3.0.0
|
||||
### Recently Completed
|
||||
|
||||
- **✅ PNG Metadata Analysis**: Complete ComfyUI workflow extraction from images
|
||||
- **✅ Standalone Metadata Viewer**: Dedicated tool for analyzing any ComfyUI image
|
||||
- **✅ Advanced Admin Dashboard**: Comprehensive management interface with modern UI
|
||||
- **✅ Integrated Metadata Panel**: Real-time workflow analysis in image viewer
|
||||
- **✅ Bulk Image Scanning**: Mass import of existing ComfyUI images
|
||||
- **✅ System Management Tools**: Backup, restore, diagnostics, and maintenance
|
||||
- **✅ Enhanced Error Handling**: Robust PNG parsing with NaN value cleaning
|
||||
- **✅ LoRA Manager Integration**: Import LoRA metadata, trigger words, and preview images
|
||||
- **✅ Folder Filter**: Browse and filter prompts by output subdirectory
|
||||
- **✅ Multi-Directory Gallery**: Scan multiple output directories simultaneously
|
||||
- **✅ WD14 Tagger**: Fast ONNX-based auto-tagging with Danbooru tags
|
||||
- **✅ Tailwind v4 Migration**: ComfyUI theme token system for consistent styling
|
||||
- **✅ Tag Management Page**: Dedicated page for tag search, rename, merge, and delete
|
||||
- **✅ Junction Table Tags**: Normalized tag storage with proper foreign keys
|
||||
|
||||
### Planned Features
|
||||
|
||||
@@ -830,19 +888,28 @@ MIT License - see LICENSE file for details.
|
||||
- **🤝 Collaboration**: Share prompt collections with other users
|
||||
- **🧠 AI Suggestions**: Recommend similar prompts based on metadata analysis
|
||||
- **📈 Advanced Analytics**: Detailed usage statistics and trends with workflow insights
|
||||
- **🔌 Plugin System**: Support for third-party extensions and custom analyzers
|
||||
- **🎨 Enhanced Batch Processing**: Advanced bulk operations with metadata editing
|
||||
- **🔄 Workflow Templates**: Save and reuse common workflow patterns
|
||||
- **📊 Visual Analytics**: Charts and graphs for prompt effectiveness analysis
|
||||
|
||||
### Integration Ideas
|
||||
|
||||
- **Workflow linking**: Connect prompts to specific workflow templates
|
||||
- **Image analysis**: Analyze generated images to improve suggestions
|
||||
- **Version control**: Track prompt iterations and effectiveness
|
||||
|
||||
## Changelog
|
||||
|
||||
### v3.2.1 (LoRA Manager Integration)
|
||||
|
||||
- **🔗 LoRA Manager Integration**: Import LoRA metadata, trigger words, and CivitAI example images from [ComfyUI-Lora-Manager](https://github.com/willchil/ComfyUI-Lora-Manager) into your prompt database
|
||||
- **💉 Auto-Inject Trigger Words**: Optionally append LoRA trigger words when `<lora:name:weight>` is detected in prompts during encoding
|
||||
- **🔑 CivitAI API Key Support**: Authenticate with CivitAI to download NSFW preview images
|
||||
- **📥 Import Progress Modal**: Real-time SSE streaming progress during LoRA import with per-model status
|
||||
|
||||
> **Note:** LoRA Manager support is a WIP — this was a highly requested feature. Please [open issues](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues) for any bugs or feature requests.
|
||||
|
||||
### v3.2.0 (Folder Filter & QoL Improvements)
|
||||
|
||||
- **📁 Folder Filter**: New folder dropdown in the search panel to filter prompts by output subdirectory
|
||||
- **📂 Multi-Directory Gallery Scan**: Configure multiple output directories and browse them all in one gallery
|
||||
- **🖼️ Filmstrip Prompt Display**: Show prompt text and copy button in the filmstrip image viewer
|
||||
- **🖱️ Click-to-Close Viewer**: Click outside the image viewer to close it
|
||||
- **🐛 Bug Fixes**: Fixed database path config, UnboundLocalError in text inputs, node caching skip, diagnostics singleton lifecycle
|
||||
|
||||
### v3.1.0 (WD14 Tagger, Tailwind v4 & Major Refactors)
|
||||
|
||||
- **🏷️ WD14 Tagger Support**: Added WD14 SwinV2 and WD14 ViT as new auto-tag model options — fast ONNX-based classifiers (~400MB) that output Danbooru tags with confidence scores, no prompt needed
|
||||
|
||||
+48
-6
@@ -190,6 +190,7 @@ class PromptDatabase:
|
||||
folder: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
tag_partial: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search prompts with various filters.
|
||||
@@ -205,6 +206,7 @@ class PromptDatabase:
|
||||
folder: Filter by subfolder name in generated image paths
|
||||
limit: Maximum number of results
|
||||
offset: Number of results to skip
|
||||
tag_partial: Use LIKE matching for tags instead of exact match
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing prompt data
|
||||
@@ -222,12 +224,20 @@ class PromptDatabase:
|
||||
|
||||
if tags:
|
||||
for tag in tags:
|
||||
query_parts.append(
|
||||
"AND prompts.id IN ("
|
||||
" SELECT pt.prompt_id FROM prompt_tags pt"
|
||||
" JOIN tags t ON pt.tag_id = t.id WHERE t.name = ?)"
|
||||
)
|
||||
params.append(tag)
|
||||
if tag_partial:
|
||||
query_parts.append(
|
||||
"AND prompts.id IN ("
|
||||
" SELECT pt.prompt_id FROM prompt_tags pt"
|
||||
" JOIN tags t ON pt.tag_id = t.id WHERE t.name LIKE ?)"
|
||||
)
|
||||
params.append(f"%{tag}%")
|
||||
else:
|
||||
query_parts.append(
|
||||
"AND prompts.id IN ("
|
||||
" SELECT pt.prompt_id FROM prompt_tags pt"
|
||||
" JOIN tags t ON pt.tag_id = t.id WHERE t.name = ?)"
|
||||
)
|
||||
params.append(tag)
|
||||
|
||||
if folder:
|
||||
# Escape LIKE wildcards in the folder name
|
||||
@@ -444,6 +454,38 @@ class PromptDatabase:
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_prompts_by_category(self, category: str) -> int:
|
||||
"""Delete all prompts with the given category.
|
||||
|
||||
Returns:
|
||||
Number of prompts deleted.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
# Get IDs first for cascade cleanup
|
||||
ids = [
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM prompts WHERE category = ?", (category,)
|
||||
).fetchall()
|
||||
]
|
||||
if not ids:
|
||||
return 0
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
conn.execute(
|
||||
f"DELETE FROM generated_images WHERE prompt_id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM prompt_tags WHERE prompt_id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM prompts WHERE id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
def get_all_categories(self) -> List[str]:
|
||||
"""
|
||||
Get all unique categories from the database.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -148,6 +148,9 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
parts.append(append_text.strip())
|
||||
final_text = " ".join(parts)
|
||||
|
||||
# Inject LoRA trigger words if integration is enabled
|
||||
final_text = self._inject_lora_trigger_words(final_text)
|
||||
|
||||
# Use the combined text for encoding
|
||||
encoding_text = final_text
|
||||
|
||||
|
||||
@@ -102,6 +102,45 @@ class PromptManagerBase:
|
||||
self.logger.error(f"Error saving prompt to database: {e}")
|
||||
return None
|
||||
|
||||
def _inject_lora_trigger_words(self, text: str) -> str:
|
||||
"""Append LoRA trigger words if integration is enabled.
|
||||
|
||||
Returns the text unchanged if the integration is disabled or
|
||||
LoraManager is not installed.
|
||||
"""
|
||||
try:
|
||||
from .py.config import IntegrationConfig
|
||||
except ImportError:
|
||||
try:
|
||||
from py.config import IntegrationConfig
|
||||
except ImportError:
|
||||
return text
|
||||
|
||||
if (
|
||||
not IntegrationConfig.LORA_MANAGER_ENABLED
|
||||
or not IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED
|
||||
):
|
||||
return text
|
||||
|
||||
try:
|
||||
from .py.lora_utils import get_trigger_cache, inject_trigger_words
|
||||
except ImportError:
|
||||
try:
|
||||
from py.lora_utils import get_trigger_cache, inject_trigger_words
|
||||
except ImportError:
|
||||
return text
|
||||
|
||||
cache = get_trigger_cache()
|
||||
|
||||
# Lazy-load cache on first use
|
||||
if not cache.is_loaded and IntegrationConfig.LORA_MANAGER_PATH:
|
||||
cache.load(IntegrationConfig.LORA_MANAGER_PATH)
|
||||
|
||||
modified, injected = inject_trigger_words(text, cache)
|
||||
if injected:
|
||||
self.logger.info(f"Injected trigger words: {', '.join(injected)}")
|
||||
return modified
|
||||
|
||||
def _generate_hash(self, text: str) -> str:
|
||||
"""Generate SHA256 hash for the prompt text.
|
||||
|
||||
|
||||
@@ -142,6 +142,9 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
parts.append(append_text.strip())
|
||||
final_text = " ".join(parts)
|
||||
|
||||
# Inject LoRA trigger words if integration is enabled
|
||||
final_text = self._inject_lora_trigger_words(final_text)
|
||||
|
||||
# For database storage, save the original main text with metadata about prepend/append
|
||||
storage_text = text
|
||||
|
||||
|
||||
+66
-22
@@ -5,6 +5,7 @@ This node enables batch processing workflows by outputting prompt texts with
|
||||
OUTPUT_IS_LIST=True, allowing direct connection to nodes that accept list inputs.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
try:
|
||||
@@ -79,7 +80,7 @@ class PromptSearchList(ComfyNodeABC):
|
||||
IO.STRING,
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Comma-separated list of tags to filter by",
|
||||
"tooltip": "Comma-separated list of tags to filter by (partial match)",
|
||||
},
|
||||
),
|
||||
"min_rating": (
|
||||
@@ -100,20 +101,33 @@ class PromptSearchList(ComfyNodeABC):
|
||||
"tooltip": "Maximum number of results to return",
|
||||
},
|
||||
),
|
||||
"skip_multipart": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Skip prompts containing Clip_1/Clip_2/etc. multi-part markers",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.STRING,)
|
||||
RETURN_NAMES = ("prompts",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
OUTPUT_TOOLTIPS = ("List of prompt texts matching the search criteria.",)
|
||||
RETURN_TYPES = (IO.STRING, IO.STRING, IO.INT)
|
||||
RETURN_NAMES = ("prompts", "preview", "count")
|
||||
OUTPUT_IS_LIST = (True, False, False)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"List of prompt texts matching the search criteria.",
|
||||
"Preview of all found prompts (for display).",
|
||||
"Number of prompts found.",
|
||||
)
|
||||
FUNCTION = "search"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
DESCRIPTION = (
|
||||
"Searches the prompt database and outputs matching prompts as a list. "
|
||||
"Use this node to retrieve stored prompts for batch processing workflows. "
|
||||
"Connect to nodes that accept list inputs like String OutputList or batch processors."
|
||||
"'prompts' output sends each prompt individually to downstream nodes (batch). "
|
||||
"'preview' output shows all found prompts as one text block. "
|
||||
"'count' output shows how many prompts were found."
|
||||
)
|
||||
|
||||
def search(
|
||||
@@ -123,7 +137,8 @@ class PromptSearchList(ComfyNodeABC):
|
||||
tags: str = "",
|
||||
min_rating: int = 0,
|
||||
limit: int = 50,
|
||||
) -> Tuple[List[str]]:
|
||||
skip_multipart: bool = True,
|
||||
):
|
||||
"""
|
||||
Search for prompts matching the given criteria.
|
||||
|
||||
@@ -135,7 +150,7 @@ class PromptSearchList(ComfyNodeABC):
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
Tuple containing a list of prompt text strings
|
||||
Dict with 'ui' display info and 'result' tuple
|
||||
"""
|
||||
try:
|
||||
# Parse tags from comma-separated string
|
||||
@@ -143,38 +158,67 @@ class PromptSearchList(ComfyNodeABC):
|
||||
if tags and tags.strip():
|
||||
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||
|
||||
# Perform search
|
||||
# Perform search with partial tag matching for discovery
|
||||
results = self.db.search_prompts(
|
||||
text=text.strip() if text and text.strip() else None,
|
||||
category=category.strip() if category and category.strip() else None,
|
||||
tags=tags_list,
|
||||
tag_partial=True,
|
||||
rating_min=min_rating if min_rating > 0 else None,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Extract just the prompt text from results
|
||||
prompt_texts = [r["text"] for r in results if r.get("text")]
|
||||
# Extract prompt text, collapsing newlines to spaces so downstream
|
||||
# nodes that split on \n (e.g. StringOutputList) treat each DB
|
||||
# entry as a single prompt.
|
||||
prompt_texts = [
|
||||
" ".join(r["text"].split()) for r in results if r.get("text")
|
||||
]
|
||||
|
||||
self.logger.debug(
|
||||
f"Search returned {len(prompt_texts)} prompts "
|
||||
f"(text='{text[:20]}...' if text else '', category='{category}', "
|
||||
f"tags={tags_list}, min_rating={min_rating}, limit={limit})"
|
||||
)
|
||||
# Filter out multi-part prompts (Clip_1/Clip_2 markers)
|
||||
if skip_multipart:
|
||||
prompt_texts = [
|
||||
p for p in prompt_texts if not re.search(r"Clip_\d+", p)
|
||||
]
|
||||
|
||||
# Return empty list if no results (not an error)
|
||||
# Filter out prompts that are only LoRA tags with no actual content
|
||||
LORA_ONLY = re.compile(r"^(\s*<lora:[^>]+>\s*)+$")
|
||||
prompt_texts = [p for p in prompt_texts if not LORA_ONLY.match(p)]
|
||||
|
||||
count = len(prompt_texts)
|
||||
|
||||
# Build a preview: numbered list of truncated prompts
|
||||
preview_lines = []
|
||||
for i, p in enumerate(prompt_texts, 1):
|
||||
truncated = p[:120] + "..." if len(p) > 120 else p
|
||||
preview_lines.append(f"[{i}] {truncated}")
|
||||
preview = "\n".join(preview_lines) if preview_lines else "No results found"
|
||||
|
||||
# Must return at least one element — ComfyUI's slice_dict
|
||||
# indexes into OUTPUT_IS_LIST outputs and crashes on empty lists.
|
||||
if not prompt_texts:
|
||||
self.logger.info("Search returned no results")
|
||||
return ([],)
|
||||
return {
|
||||
"ui": {"text": ["No results found"]},
|
||||
"result": ([""], preview, count),
|
||||
}
|
||||
|
||||
return (prompt_texts,)
|
||||
return {
|
||||
"ui": {"text": [f"Found {count} prompts"]},
|
||||
"result": (prompt_texts, preview, count),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Search error: {e}", exc_info=True)
|
||||
# Return empty list on error to avoid breaking workflows
|
||||
return ([],)
|
||||
return {
|
||||
"ui": {"text": [f"Search error: {e}"]},
|
||||
"result": ([""], f"Error: {e}", 0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, text="", category="", tags="", min_rating=0, limit=50):
|
||||
def IS_CHANGED(
|
||||
cls, text="", category="", tags="", min_rating=0, limit=50, skip_multipart=True
|
||||
):
|
||||
"""
|
||||
Always re-execute to get fresh results from the database.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
@@ -99,6 +100,7 @@ class PromptManagerAPI(
|
||||
AdminRoutesMixin,
|
||||
LoggingRoutesMixin,
|
||||
AutotagRoutesMixin,
|
||||
LoraIntegrationMixin,
|
||||
):
|
||||
"""REST API handler for PromptManager operations and web interface.
|
||||
|
||||
@@ -372,6 +374,7 @@ class PromptManagerAPI(
|
||||
self._register_admin_routes(routes)
|
||||
self._register_logging_routes(routes)
|
||||
self._register_autotag_routes(routes)
|
||||
self._register_lora_routes(routes)
|
||||
|
||||
# Register gzip compression middleware (once)
|
||||
global _gzip_registered
|
||||
|
||||
+24
-4
@@ -303,11 +303,31 @@ class ImageRoutesMixin:
|
||||
|
||||
image_path = Path(image["image_path"]).resolve()
|
||||
|
||||
# Validate path is within any configured output directory
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if output_dirs:
|
||||
# Validate path is within any allowed directory
|
||||
allowed_dirs = list(self._get_all_output_dirs())
|
||||
|
||||
# Also allow LoRA directories when integration is enabled
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
|
||||
if IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
from ..lora_utils import (
|
||||
find_lora_directories,
|
||||
get_lora_image_cache_dir,
|
||||
)
|
||||
|
||||
lora_dirs = find_lora_directories(
|
||||
IntegrationConfig.LORA_MANAGER_PATH
|
||||
)
|
||||
allowed_dirs.extend(Path(d) for d in lora_dirs)
|
||||
allowed_dirs.append(get_lora_image_cache_dir())
|
||||
except Exception:
|
||||
# LoRA integration is optional — skip if unavailable
|
||||
pass
|
||||
|
||||
if allowed_dirs:
|
||||
allowed = any(
|
||||
image_path.is_relative_to(d.resolve()) for d in output_dirs
|
||||
image_path.is_relative_to(d.resolve()) for d in allowed_dirs
|
||||
)
|
||||
if not allowed:
|
||||
return web.json_response(
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""LoraManager integration API routes for PromptManager."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
class LoraIntegrationMixin:
|
||||
"""Mixin providing LoraManager detection, scanning, and trigger word endpoints."""
|
||||
|
||||
def _register_lora_routes(self, routes):
|
||||
@routes.get("/prompt_manager/lora/detect")
|
||||
async def lora_detect_route(request):
|
||||
return await self.lora_detect(request)
|
||||
|
||||
@routes.get("/prompt_manager/lora/status")
|
||||
async def lora_status_route(request):
|
||||
return await self.lora_status(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/enable")
|
||||
async def lora_enable_route(request):
|
||||
return await self.lora_enable(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/scan")
|
||||
async def lora_scan_route(request):
|
||||
return await self.lora_scan(request)
|
||||
|
||||
@routes.get("/prompt_manager/lora/trigger-words")
|
||||
async def lora_trigger_words_route(request):
|
||||
return await self.lora_trigger_words(request)
|
||||
|
||||
@routes.post("/prompt_manager/lora/refresh-cache")
|
||||
async def lora_refresh_cache_route(request):
|
||||
return await self.lora_refresh_cache(request)
|
||||
|
||||
# ── Detection ────────────────────────────────────────────────────
|
||||
|
||||
async def lora_detect(self, request):
|
||||
"""Auto-detect LoraManager installation."""
|
||||
try:
|
||||
from ..lora_utils import detect_lora_manager
|
||||
|
||||
path = await self._run_in_executor(detect_lora_manager)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"detected": path is not None,
|
||||
"path": path or "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager detection failed: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────
|
||||
|
||||
async def lora_status(self, request):
|
||||
"""Get current LoraManager integration status."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import detect_lora_manager, get_trigger_cache
|
||||
|
||||
config = IntegrationConfig.get_config()["lora_manager"]
|
||||
cache = get_trigger_cache()
|
||||
|
||||
# Check if the configured path is still valid
|
||||
detected_path = await self._run_in_executor(
|
||||
detect_lora_manager, config.get("path", "")
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"enabled": config["enabled"],
|
||||
"path": config["path"],
|
||||
"trigger_words_enabled": config["trigger_words_enabled"],
|
||||
"civitai_api_key": config.get("civitai_api_key", ""),
|
||||
"detected": detected_path is not None,
|
||||
"detected_path": detected_path or "",
|
||||
"trigger_cache_loaded": cache.is_loaded,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager status check failed: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
# ── Enable / Disable ─────────────────────────────────────────────
|
||||
|
||||
async def lora_enable(self, request):
|
||||
"""Enable or disable LoraManager integration and save to config.json."""
|
||||
try:
|
||||
data = await request.json()
|
||||
enabled = data.get("enabled", False)
|
||||
path = data.get("path", "")
|
||||
trigger_words = data.get("trigger_words_enabled", False)
|
||||
civitai_key = data.get("civitai_api_key", "")
|
||||
|
||||
from ..config import IntegrationConfig, PromptManagerConfig
|
||||
from ..lora_utils import detect_lora_manager, get_trigger_cache
|
||||
|
||||
# If enabling, validate the path
|
||||
if enabled:
|
||||
resolved = await self._run_in_executor(detect_lora_manager, path)
|
||||
if not resolved:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "LoraManager not found at the specified path",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
path = resolved
|
||||
|
||||
# Update in-memory config
|
||||
IntegrationConfig.LORA_MANAGER_ENABLED = enabled
|
||||
IntegrationConfig.LORA_MANAGER_PATH = path
|
||||
IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = trigger_words
|
||||
IntegrationConfig.CIVITAI_API_KEY = civitai_key
|
||||
|
||||
# Persist to config.json
|
||||
config_dir = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
config_file = os.path.join(config_dir, "config.json")
|
||||
PromptManagerConfig.save_to_file(config_file)
|
||||
|
||||
# Load trigger word cache if enabling
|
||||
cache = get_trigger_cache()
|
||||
if enabled and trigger_words and path:
|
||||
count = await self._run_in_executor(cache.load, path)
|
||||
self.logger.info(f"Trigger word cache loaded: {count} LoRAs")
|
||||
elif not enabled:
|
||||
cache.clear()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"enabled": enabled,
|
||||
"path": path,
|
||||
"trigger_words_enabled": trigger_words,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoraManager enable/disable failed: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
# ── Scan LoRA example images ─────────────────────────────────────
|
||||
|
||||
async def lora_scan(self, request):
|
||||
"""Scan LoraManager metadata and import LoRA info + preview images.
|
||||
|
||||
Streams progress as SSE, matching the existing scan pattern.
|
||||
"""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import (
|
||||
download_civitai_images,
|
||||
find_lora_directories,
|
||||
get_example_prompt_from_metadata,
|
||||
get_lora_image_cache_dir,
|
||||
get_preview_images_from_metadata,
|
||||
get_trigger_words_from_metadata,
|
||||
get_model_name_from_metadata,
|
||||
read_lora_metadata,
|
||||
)
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "LoraManager integration is not enabled",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if not lm_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "LoraManager path not configured"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
async def send_progress(data):
|
||||
line = f"data: {json.dumps(data)}\n\n"
|
||||
await response.write(line.encode("utf-8"))
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": "Clearing previous lora-manager imports...",
|
||||
"progress": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Clear previous imports so reimport is always clean
|
||||
await self._run_in_executor(
|
||||
self.db.delete_prompts_by_category, "lora-manager"
|
||||
)
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": "Finding LoRA directories...",
|
||||
"progress": 2,
|
||||
}
|
||||
)
|
||||
|
||||
lora_dirs = await self._run_in_executor(find_lora_directories, lm_path)
|
||||
|
||||
# Collect all metadata files
|
||||
meta_files = []
|
||||
for d in lora_dirs:
|
||||
dir_path = Path(d)
|
||||
meta_files.extend(dir_path.rglob("*.metadata.json"))
|
||||
|
||||
total = len(meta_files)
|
||||
imported = 0
|
||||
skipped = 0
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": f"Found {total} LoRA metadata files",
|
||||
"progress": 5,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
cache_dir = get_lora_image_cache_dir()
|
||||
|
||||
for i, meta_file in enumerate(meta_files):
|
||||
metadata = await self._run_in_executor(read_lora_metadata, meta_file)
|
||||
if not metadata:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
model_name = get_model_name_from_metadata(metadata)
|
||||
trigger_words = get_trigger_words_from_metadata(metadata)
|
||||
|
||||
# Collect all images: local previews + downloaded civitai examples
|
||||
preview_paths = await self._run_in_executor(
|
||||
get_preview_images_from_metadata, metadata, meta_file
|
||||
)
|
||||
civitai_paths = await self._run_in_executor(
|
||||
download_civitai_images,
|
||||
metadata,
|
||||
meta_file,
|
||||
cache_dir,
|
||||
IntegrationConfig.CIVITAI_API_KEY,
|
||||
)
|
||||
|
||||
# Merge, local first, dedup
|
||||
seen = set(preview_paths)
|
||||
all_images = list(preview_paths)
|
||||
for cp in civitai_paths:
|
||||
if cp not in seen:
|
||||
all_images.append(cp)
|
||||
seen.add(cp)
|
||||
|
||||
# Build prompt text: prefer example prompt, then model name
|
||||
example_prompt = get_example_prompt_from_metadata(metadata)
|
||||
prompt_text = example_prompt or model_name
|
||||
|
||||
# Build tags
|
||||
tags = ["lora-manager", f"lora:{model_name}"]
|
||||
tags.extend(trigger_words)
|
||||
|
||||
# Save to database via existing mechanism
|
||||
try:
|
||||
import hashlib
|
||||
|
||||
prompt_hash = hashlib.sha256(
|
||||
prompt_text.strip().lower().encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
existing = await self._run_in_executor(
|
||||
self.db.get_prompt_by_hash, prompt_hash
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Link all images
|
||||
for pp in all_images:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
existing["id"],
|
||||
pp,
|
||||
)
|
||||
skipped += 1
|
||||
else:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.save_prompt,
|
||||
prompt_text,
|
||||
"lora-manager", # category
|
||||
tags,
|
||||
None, # rating
|
||||
None, # notes
|
||||
prompt_hash,
|
||||
)
|
||||
|
||||
if prompt_id:
|
||||
for pp in all_images:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
prompt_id,
|
||||
pp,
|
||||
)
|
||||
imported += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to import LoRA {model_name}: {e}")
|
||||
skipped += 1
|
||||
|
||||
# Progress update for every LoRA
|
||||
progress = int(5 + (90 * (i + 1) / max(total, 1)))
|
||||
img_count = len(all_images)
|
||||
status = f"{model_name}"
|
||||
if img_count:
|
||||
status += f" ({img_count} images)"
|
||||
await send_progress(
|
||||
{
|
||||
"type": "progress",
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"processed": i + 1,
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
}
|
||||
)
|
||||
|
||||
await send_progress(
|
||||
{
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
"total": total,
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
}
|
||||
)
|
||||
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"LoRA scan failed: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
# ── Trigger word endpoints ───────────────────────────────────────
|
||||
|
||||
async def lora_trigger_words(self, request):
|
||||
"""Look up trigger words for a specific LoRA name."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import get_trigger_cache
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "LoraManager integration not enabled"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
lora_name = request.query.get("name", "")
|
||||
if not lora_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing 'name' query parameter"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
cache = get_trigger_cache()
|
||||
if not cache.is_loaded:
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if lm_path:
|
||||
await self._run_in_executor(cache.load, lm_path)
|
||||
|
||||
words = cache.get_trigger_words(lora_name)
|
||||
return web.json_response(
|
||||
{"success": True, "lora": lora_name, "trigger_words": words}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Trigger word lookup failed: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def lora_refresh_cache(self, request):
|
||||
"""Force-refresh the trigger word cache from disk."""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
from ..lora_utils import get_trigger_cache
|
||||
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "LoraManager integration not enabled"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
lm_path = IntegrationConfig.LORA_MANAGER_PATH
|
||||
if not lm_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "LoraManager path not configured"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
cache = get_trigger_cache()
|
||||
count = await self._run_in_executor(cache.load, lm_path)
|
||||
return web.json_response(
|
||||
{"success": True, "loras_with_trigger_words": count}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Trigger cache refresh failed: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
@@ -201,6 +201,43 @@ class GalleryConfig:
|
||||
cls.METADATA_EXTRACTION_TIMEOUT = performance["metadata_extraction_timeout"]
|
||||
|
||||
|
||||
class IntegrationConfig:
|
||||
"""Configuration for third-party extension integrations.
|
||||
|
||||
Manages opt-in integration settings for extensions like LoraManager.
|
||||
All integrations are disabled by default so PromptManager works standalone.
|
||||
"""
|
||||
|
||||
# LoraManager integration
|
||||
LORA_MANAGER_ENABLED = False
|
||||
LORA_MANAGER_PATH = "" # Auto-detected if empty
|
||||
LORA_TRIGGER_WORDS_ENABLED = False # Auto-inject trigger words into prompts
|
||||
CIVITAI_API_KEY = "" # Required to download NSFW example images
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"lora_manager": {
|
||||
"enabled": cls.LORA_MANAGER_ENABLED,
|
||||
"path": cls.LORA_MANAGER_PATH,
|
||||
"trigger_words_enabled": cls.LORA_TRIGGER_WORDS_ENABLED,
|
||||
"civitai_api_key": cls.CIVITAI_API_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Dict[str, Any]):
|
||||
lora = new_config.get("lora_manager", {})
|
||||
if "enabled" in lora:
|
||||
cls.LORA_MANAGER_ENABLED = lora["enabled"]
|
||||
if "path" in lora:
|
||||
cls.LORA_MANAGER_PATH = lora["path"]
|
||||
if "trigger_words_enabled" in lora:
|
||||
cls.LORA_TRIGGER_WORDS_ENABLED = lora["trigger_words_enabled"]
|
||||
if "civitai_api_key" in lora:
|
||||
cls.CIVITAI_API_KEY = lora["civitai_api_key"]
|
||||
|
||||
|
||||
class PromptManagerConfig:
|
||||
"""Main configuration class for PromptManager core functionality.
|
||||
|
||||
@@ -274,6 +311,7 @@ class PromptManagerConfig:
|
||||
"auto_backup_interval": cls.AUTO_BACKUP_INTERVAL,
|
||||
},
|
||||
"gallery": GalleryConfig.get_config(),
|
||||
"integrations": IntegrationConfig.get_config(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -389,6 +427,10 @@ class PromptManagerConfig:
|
||||
if "gallery" in new_config:
|
||||
GalleryConfig.update_config(new_config["gallery"])
|
||||
|
||||
# Update integration config
|
||||
if "integrations" in new_config:
|
||||
IntegrationConfig.update_config(new_config["integrations"])
|
||||
|
||||
|
||||
# Load configuration on import
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
"""Utilities for LoraManager integration.
|
||||
|
||||
Provides detection, metadata reading, and trigger word lookup for
|
||||
ComfyUI-Lora-Manager (https://github.com/willmiao/ComfyUI-Lora-Manager).
|
||||
|
||||
All functions are safe to call when LoraManager is not installed — they
|
||||
return empty results rather than raising.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("prompt_manager.lora_utils")
|
||||
|
||||
# ── LoraManager detection ────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_comfyui_root() -> Optional[Path]:
|
||||
"""Walk upward from this file to find the ComfyUI root (contains main.py).
|
||||
|
||||
Tries both the resolved (real) path and the unresolved path to handle
|
||||
symlinked custom_nodes installations.
|
||||
"""
|
||||
start_paths = [Path(__file__).resolve().parent]
|
||||
|
||||
# If installed via symlink, the unresolved path leads through custom_nodes/
|
||||
raw_path = Path(__file__).parent
|
||||
if raw_path.resolve() != raw_path:
|
||||
start_paths.append(raw_path)
|
||||
|
||||
# Also try via folder_paths if available (ComfyUI runtime)
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
base = Path(folder_paths.base_path)
|
||||
if base.is_dir():
|
||||
return base
|
||||
except (ImportError, AttributeError):
|
||||
# folder_paths unavailable — not running inside ComfyUI runtime
|
||||
pass
|
||||
|
||||
for start in start_paths:
|
||||
current = start
|
||||
for _ in range(10):
|
||||
if (current / "main.py").exists() and (current / "custom_nodes").exists():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_lora_manager(custom_path: str = "") -> Optional[str]:
|
||||
"""Return the absolute path to ComfyUI-Lora-Manager if installed.
|
||||
|
||||
Args:
|
||||
custom_path: User-provided override path. Checked first.
|
||||
|
||||
Returns:
|
||||
Absolute path string, or None if not found.
|
||||
"""
|
||||
# 1. User override
|
||||
if custom_path:
|
||||
p = Path(custom_path)
|
||||
if p.is_dir() and _looks_like_lora_manager(p):
|
||||
return str(p.resolve())
|
||||
|
||||
# 2. Auto-detect via custom_nodes (case-insensitive scan)
|
||||
root = find_comfyui_root()
|
||||
if root:
|
||||
custom_nodes = root / "custom_nodes"
|
||||
if custom_nodes.is_dir():
|
||||
for entry in custom_nodes.iterdir():
|
||||
if (
|
||||
entry.is_dir()
|
||||
and "lora" in entry.name.lower()
|
||||
and "manager" in entry.name.lower()
|
||||
and _looks_like_lora_manager(entry)
|
||||
):
|
||||
return str(entry.resolve())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_lora_manager(path: Path) -> bool:
|
||||
"""Heuristic: does this directory look like a LoraManager install?"""
|
||||
# Must have __init__.py (ComfyUI extension) or README.md
|
||||
has_init = (path / "__init__.py").exists()
|
||||
if not has_init:
|
||||
return False
|
||||
# Check for characteristic structure: py/ dir, or any .metadata.json nearby
|
||||
return (path / "py").is_dir() or (path / "lora_manager").is_dir()
|
||||
|
||||
|
||||
# ── Metadata reading ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_lora_directories(lora_manager_path: str) -> List[str]:
|
||||
"""Find directories that contain LoRA models (with .metadata.json files).
|
||||
|
||||
Searches: ComfyUI models/loras, extra_model_paths.yaml lora dirs,
|
||||
and the LoraManager extension dir itself.
|
||||
"""
|
||||
dirs = set()
|
||||
lm_path = Path(lora_manager_path)
|
||||
|
||||
root = find_comfyui_root()
|
||||
if root:
|
||||
# Default models/loras
|
||||
models_loras = root / "models" / "loras"
|
||||
if models_loras.is_dir():
|
||||
dirs.add(str(models_loras.resolve()))
|
||||
|
||||
# Extra model paths from ComfyUI config
|
||||
for extra_dir in _get_extra_lora_paths(root):
|
||||
if extra_dir.is_dir():
|
||||
dirs.add(str(extra_dir.resolve()))
|
||||
|
||||
# Also try folder_paths at runtime (catches all configured paths)
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
for p in folder_paths.get_folder_paths("loras"):
|
||||
pp = Path(p)
|
||||
if pp.is_dir():
|
||||
dirs.add(str(pp.resolve()))
|
||||
except (ImportError, AttributeError):
|
||||
# folder_paths unavailable — not running inside ComfyUI runtime
|
||||
pass
|
||||
|
||||
# Check for any .metadata.json in the LoraManager dir tree
|
||||
for meta in lm_path.rglob("*.metadata.json"):
|
||||
dirs.add(str(meta.parent.resolve()))
|
||||
|
||||
return sorted(dirs)
|
||||
|
||||
|
||||
def _get_extra_lora_paths(comfyui_root: Path) -> List[Path]:
|
||||
"""Parse extra_model_paths.yaml for additional LoRA directories."""
|
||||
results = []
|
||||
for name in ("extra_model_paths.yaml", "extra_model_paths.yml"):
|
||||
config_file = comfyui_root / name
|
||||
if not config_file.exists():
|
||||
continue
|
||||
try:
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load(config_file.read_text())
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
for section in config.values():
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
base = Path(section.get("base_path", ""))
|
||||
loras_val = section.get("loras", "")
|
||||
if not loras_val:
|
||||
continue
|
||||
for line in str(loras_val).strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
p = Path(line)
|
||||
if not p.is_absolute():
|
||||
p = base / line
|
||||
if p.is_dir():
|
||||
results.append(p)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse {config_file}: {e}")
|
||||
return results
|
||||
|
||||
|
||||
def read_lora_metadata(metadata_path: Path) -> Optional[Dict]:
|
||||
"""Read and parse a single .metadata.json file.
|
||||
|
||||
Returns:
|
||||
Parsed dict, or None on failure.
|
||||
"""
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug(f"Failed to read {metadata_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_civitai(metadata: Dict) -> Dict:
|
||||
"""Safely get the civitai dict, handling None values."""
|
||||
return metadata.get("civitai") or {}
|
||||
|
||||
|
||||
def get_trigger_words_from_metadata(metadata: Dict) -> List[str]:
|
||||
"""Extract trigger words from a parsed LoraManager metadata dict."""
|
||||
civitai = _get_civitai(metadata)
|
||||
words = civitai.get("trainedWords", [])
|
||||
if isinstance(words, list):
|
||||
return [w.strip() for w in words if isinstance(w, str) and w.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def get_example_prompt_from_metadata(metadata: Dict) -> Optional[str]:
|
||||
"""Extract an example prompt from civitai image metadata.
|
||||
|
||||
Looks at civitai.images[].meta.prompt for the first available example.
|
||||
"""
|
||||
civitai = _get_civitai(metadata)
|
||||
images = civitai.get("images", []) or []
|
||||
for img in images:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
meta = img.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
prompt = meta.get("prompt", "")
|
||||
if isinstance(prompt, str) and prompt.strip():
|
||||
return prompt.strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_civitai_image_urls(metadata: Dict) -> List[str]:
|
||||
"""Extract all civitai example image URLs from metadata."""
|
||||
civitai = _get_civitai(metadata)
|
||||
urls = []
|
||||
for img in civitai.get("images", []) or []:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
url = img.get("url", "")
|
||||
if isinstance(url, str) and url.strip():
|
||||
urls.append(url.strip())
|
||||
return urls
|
||||
|
||||
|
||||
def get_model_name_from_metadata(metadata: Dict) -> str:
|
||||
"""Extract the model display name from metadata."""
|
||||
name = metadata.get("model_name", "")
|
||||
if not name:
|
||||
civitai = _get_civitai(metadata)
|
||||
model = civitai.get("model") or {}
|
||||
name = model.get("name", "")
|
||||
if not name:
|
||||
name = metadata.get("file_name", "unknown")
|
||||
return name
|
||||
|
||||
|
||||
def get_preview_images_from_metadata(metadata: Dict, metadata_path: Path) -> List[str]:
|
||||
"""Find all local preview/example image paths for a LoRA.
|
||||
|
||||
Returns:
|
||||
List of absolute path strings to image files.
|
||||
"""
|
||||
results = []
|
||||
lora_dir = metadata_path.parent
|
||||
file_name = metadata.get("file_name", "")
|
||||
if not file_name:
|
||||
stem = metadata_path.name.replace(".metadata.json", "")
|
||||
file_name = stem
|
||||
|
||||
base_name = Path(file_name).stem
|
||||
|
||||
# Check standard preview naming conventions
|
||||
for ext in (
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".preview.png",
|
||||
".preview.jpg",
|
||||
".preview.jpeg",
|
||||
):
|
||||
candidate = lora_dir / f"{base_name}{ext}"
|
||||
if candidate.exists():
|
||||
results.append(str(candidate.resolve()))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_preview_image_from_metadata(
|
||||
metadata: Dict, metadata_path: Path
|
||||
) -> Optional[str]:
|
||||
"""Find the first preview image path for a LoRA (backward compat)."""
|
||||
images = get_preview_images_from_metadata(metadata, metadata_path)
|
||||
return images[0] if images else None
|
||||
|
||||
|
||||
_THUMB_MAX_SIZE = 512
|
||||
|
||||
|
||||
def _download_one(url: str, local_path: Path, api_key: str) -> Optional[str]:
|
||||
"""Download a single image, resize to thumbnail, save as JPEG."""
|
||||
try:
|
||||
headers = {"User-Agent": "ComfyUI-PromptManager/1.0"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
raw = resp.read()
|
||||
|
||||
# Resize to thumbnail to save disk space
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(raw))
|
||||
img.thumbnail((_THUMB_MAX_SIZE, _THUMB_MAX_SIZE), Image.LANCZOS)
|
||||
img = img.convert("RGB")
|
||||
img.save(str(local_path), "JPEG", quality=85)
|
||||
|
||||
return str(local_path.resolve())
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def download_civitai_images(
|
||||
metadata: Dict, metadata_path: Path, cache_dir: Path, api_key: str = ""
|
||||
) -> List[str]:
|
||||
"""Download civitai example images to a local cache directory.
|
||||
|
||||
Uses thumbnail URLs (512px) instead of full-size originals, and
|
||||
downloads in parallel (up to 8 concurrent) for speed.
|
||||
|
||||
Args:
|
||||
api_key: CivitAI API key for authenticated downloads (NSFW content).
|
||||
|
||||
Returns:
|
||||
List of absolute paths to downloaded image files.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
civitai = _get_civitai(metadata)
|
||||
images = civitai.get("images", []) or []
|
||||
if not images:
|
||||
return []
|
||||
|
||||
file_name = metadata.get("file_name", "")
|
||||
if not file_name:
|
||||
file_name = metadata_path.name.replace(".metadata.json", "")
|
||||
lora_stem = Path(file_name).stem
|
||||
|
||||
lora_cache = cache_dir / lora_stem
|
||||
lora_cache.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build download tasks
|
||||
cached = []
|
||||
tasks = [] # (url, local_path)
|
||||
for img in images:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
url = img.get("url", "")
|
||||
if not isinstance(url, str) or not url.startswith("http"):
|
||||
continue
|
||||
|
||||
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
||||
local_path = lora_cache / f"{url_hash}.jpg"
|
||||
|
||||
if local_path.exists():
|
||||
cached.append(str(local_path.resolve()))
|
||||
else:
|
||||
tasks.append((url, local_path))
|
||||
|
||||
if not tasks:
|
||||
return cached
|
||||
|
||||
# Download in parallel
|
||||
downloaded = []
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = [
|
||||
pool.submit(_download_one, url, path, api_key) for url, path in tasks
|
||||
]
|
||||
for fut in futures:
|
||||
result = fut.result()
|
||||
if result:
|
||||
downloaded.append(result)
|
||||
|
||||
return cached + downloaded
|
||||
|
||||
|
||||
def get_lora_image_cache_dir() -> Path:
|
||||
"""Get the directory used to cache downloaded LoRA example images."""
|
||||
# Store in the extension's own directory
|
||||
ext_root = Path(__file__).resolve().parent.parent
|
||||
cache = ext_root / "data" / "lora_images"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
return cache
|
||||
|
||||
|
||||
def get_example_images_dir(lora_manager_path: str) -> Optional[str]:
|
||||
"""Find the LoraManager example_images directory."""
|
||||
lm_path = Path(lora_manager_path)
|
||||
|
||||
# Direct subdirectory
|
||||
candidate = lm_path / "example_images"
|
||||
if candidate.is_dir():
|
||||
return str(candidate.resolve())
|
||||
|
||||
# Search one level in user data dirs
|
||||
for child in lm_path.iterdir():
|
||||
if child.is_dir():
|
||||
sub = child / "example_images"
|
||||
if sub.is_dir():
|
||||
return str(sub.resolve())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Trigger word cache & injection ───────────────────────────────────
|
||||
|
||||
_LORA_PATTERN = re.compile(r"<lora:([^:>]+):[^>]+>", re.IGNORECASE)
|
||||
|
||||
|
||||
class TriggerWordCache:
|
||||
"""Thread-safe cache mapping LoRA names to their trigger words.
|
||||
|
||||
Built lazily on first access, refreshable on demand.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, List[str]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._loaded = False
|
||||
|
||||
def load(self, lora_manager_path: str) -> int:
|
||||
"""Scan LoRA metadata files and build the trigger word mapping.
|
||||
|
||||
Returns:
|
||||
Number of LoRAs with trigger words found.
|
||||
"""
|
||||
new_cache: Dict[str, List[str]] = {}
|
||||
|
||||
lora_dirs = find_lora_directories(lora_manager_path)
|
||||
for lora_dir in lora_dirs:
|
||||
dir_path = Path(lora_dir)
|
||||
for meta_file in dir_path.rglob("*.metadata.json"):
|
||||
metadata = read_lora_metadata(meta_file)
|
||||
if not metadata:
|
||||
continue
|
||||
|
||||
words = get_trigger_words_from_metadata(metadata)
|
||||
if not words:
|
||||
continue
|
||||
|
||||
# Key by filename stem (what appears in <lora:NAME:weight>)
|
||||
file_name = metadata.get("file_name", "")
|
||||
if file_name:
|
||||
stem = Path(file_name).stem
|
||||
new_cache[stem.lower()] = words
|
||||
|
||||
# Also key by the metadata file stem
|
||||
meta_stem = meta_file.name.replace(".metadata.json", "")
|
||||
if meta_stem.lower() not in new_cache:
|
||||
new_cache[meta_stem.lower()] = words
|
||||
|
||||
with self._lock:
|
||||
self._cache = new_cache
|
||||
self._loaded = True
|
||||
|
||||
logger.info(
|
||||
f"Trigger word cache loaded: {len(new_cache)} LoRAs with trigger words"
|
||||
)
|
||||
return len(new_cache)
|
||||
|
||||
def get_trigger_words(self, lora_name: str) -> List[str]:
|
||||
"""Look up trigger words for a LoRA by name (case-insensitive)."""
|
||||
with self._lock:
|
||||
return self._cache.get(lora_name.lower(), [])
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
with self._lock:
|
||||
return self._loaded
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._loaded = False
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
_trigger_cache = TriggerWordCache()
|
||||
|
||||
|
||||
def get_trigger_cache() -> TriggerWordCache:
|
||||
return _trigger_cache
|
||||
|
||||
|
||||
def inject_trigger_words(text: str, cache: TriggerWordCache) -> Tuple[str, List[str]]:
|
||||
"""Scan text for <lora:NAME:WEIGHT> tags and append trigger words.
|
||||
|
||||
Args:
|
||||
text: The prompt text potentially containing lora tags.
|
||||
cache: Populated TriggerWordCache instance.
|
||||
|
||||
Returns:
|
||||
Tuple of (modified_text, list_of_injected_words).
|
||||
If no trigger words found, returns the original text unchanged.
|
||||
"""
|
||||
if not cache.is_loaded:
|
||||
return text, []
|
||||
|
||||
matches = _LORA_PATTERN.findall(text)
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
all_words = []
|
||||
for lora_name in matches:
|
||||
words = cache.get_trigger_words(lora_name)
|
||||
for w in words:
|
||||
if w.lower() not in text.lower() and w not in all_words:
|
||||
all_words.append(w)
|
||||
|
||||
if not all_words:
|
||||
return text, []
|
||||
|
||||
injected = ", ".join(all_words)
|
||||
return f"{text}, {injected}", all_words
|
||||
+1
-1
@@ -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.2.0"
|
||||
version = "3.2.2"
|
||||
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"]
|
||||
|
||||
|
||||
+85
-1
@@ -19,7 +19,7 @@ sys.modules["server"] = _mock_server
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py.config import GalleryConfig, PromptManagerConfig
|
||||
from py.config import GalleryConfig, IntegrationConfig, PromptManagerConfig
|
||||
|
||||
|
||||
class TestGalleryConfig(unittest.TestCase):
|
||||
@@ -207,5 +207,89 @@ class TestPromptManagerConfig(unittest.TestCase):
|
||||
os.unlink(tmp.name)
|
||||
|
||||
|
||||
class TestIntegrationConfig(unittest.TestCase):
|
||||
"""Test IntegrationConfig for LoRA Manager settings."""
|
||||
|
||||
def setUp(self):
|
||||
"""Save original values to restore after each test."""
|
||||
self._orig = IntegrationConfig.get_config()
|
||||
|
||||
def tearDown(self):
|
||||
"""Restore original config values."""
|
||||
IntegrationConfig.update_config(self._orig)
|
||||
|
||||
def test_reset_to_disabled(self):
|
||||
"""Integrations can be fully disabled via update_config."""
|
||||
IntegrationConfig.update_config(
|
||||
{
|
||||
"lora_manager": {
|
||||
"enabled": False,
|
||||
"path": "",
|
||||
"trigger_words_enabled": False,
|
||||
"civitai_api_key": "",
|
||||
}
|
||||
}
|
||||
)
|
||||
config = IntegrationConfig.get_config()
|
||||
lora = config["lora_manager"]
|
||||
self.assertFalse(lora["enabled"])
|
||||
self.assertEqual(lora["path"], "")
|
||||
self.assertFalse(lora["trigger_words_enabled"])
|
||||
self.assertEqual(lora["civitai_api_key"], "")
|
||||
|
||||
def test_get_config_structure(self):
|
||||
config = IntegrationConfig.get_config()
|
||||
self.assertIn("lora_manager", config)
|
||||
lora = config["lora_manager"]
|
||||
self.assertIn("enabled", lora)
|
||||
self.assertIn("path", lora)
|
||||
self.assertIn("trigger_words_enabled", lora)
|
||||
self.assertIn("civitai_api_key", lora)
|
||||
|
||||
def test_update_config_enables(self):
|
||||
IntegrationConfig.update_config(
|
||||
{
|
||||
"lora_manager": {
|
||||
"enabled": True,
|
||||
"path": "/some/path",
|
||||
"trigger_words_enabled": True,
|
||||
"civitai_api_key": "test-key-123",
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
self.assertEqual(IntegrationConfig.LORA_MANAGER_PATH, "/some/path")
|
||||
self.assertTrue(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED)
|
||||
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, "test-key-123")
|
||||
|
||||
def test_update_partial(self):
|
||||
"""Updating one field shouldn't affect others."""
|
||||
# Reset to known state first
|
||||
IntegrationConfig.update_config(
|
||||
{"lora_manager": {"enabled": False, "path": "/known"}}
|
||||
)
|
||||
# Now update only enabled
|
||||
IntegrationConfig.update_config({"lora_manager": {"enabled": True}})
|
||||
self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED)
|
||||
self.assertEqual(IntegrationConfig.LORA_MANAGER_PATH, "/known")
|
||||
|
||||
def test_update_empty_dict_noop(self):
|
||||
"""Updating with empty dict preserves current state."""
|
||||
before = IntegrationConfig.get_config()
|
||||
IntegrationConfig.update_config({})
|
||||
after = IntegrationConfig.get_config()
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_roundtrip(self):
|
||||
"""get_config → update_config → get_config should be stable."""
|
||||
IntegrationConfig.update_config(
|
||||
{"lora_manager": {"enabled": True, "path": "/test"}}
|
||||
)
|
||||
config1 = IntegrationConfig.get_config()
|
||||
IntegrationConfig.update_config(config1)
|
||||
config2 = IntegrationConfig.get_config()
|
||||
self.assertEqual(config1, config2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Database tests for LoRA Manager integration and folder filter features.
|
||||
|
||||
Tests delete_prompts_by_category, search_prompts folder filter,
|
||||
get_prompt_subfolders, and LoRA-specific prompt workflows using
|
||||
an in-memory SQLite database.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class LoraDBTestCase(unittest.TestCase):
|
||||
"""Base class with temp database setup/teardown."""
|
||||
|
||||
def setUp(self):
|
||||
self.temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
self.temp_db.close()
|
||||
self.db = PromptDatabase(self.temp_db.name)
|
||||
|
||||
def tearDown(self):
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
path = self.temp_db.name + suffix
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
def _save(self, text, category=None, tags=None):
|
||||
"""Save a prompt and return its ID."""
|
||||
return self.db.save_prompt(
|
||||
text=text,
|
||||
category=category,
|
||||
tags=tags or [],
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
)
|
||||
|
||||
def _link_image(self, prompt_id, image_path):
|
||||
"""Link a fake image to a prompt."""
|
||||
return self.db.link_image_to_prompt(
|
||||
prompt_id=str(prompt_id), image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
# ── delete_prompts_by_category ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteByCategory(LoraDBTestCase):
|
||||
"""Test delete_prompts_by_category for LoRA reimport cleanup."""
|
||||
|
||||
def test_deletes_matching_category(self):
|
||||
self._save("lora prompt 1", category="lora-manager")
|
||||
self._save("lora prompt 2", category="lora-manager")
|
||||
self._save("keep this", category="general")
|
||||
|
||||
deleted = self.db.delete_prompts_by_category("lora-manager")
|
||||
|
||||
self.assertEqual(deleted, 2)
|
||||
results = self.db.search_prompts(category="lora-manager")
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
def test_preserves_other_categories(self):
|
||||
self._save("keep this", category="general")
|
||||
self._save("and this", category="portraits")
|
||||
self.db.delete_prompts_by_category("lora-manager")
|
||||
|
||||
results = self.db.search_prompts()
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
def test_returns_zero_when_none_match(self):
|
||||
self._save("no match", category="general")
|
||||
deleted = self.db.delete_prompts_by_category("lora-manager")
|
||||
self.assertEqual(deleted, 0)
|
||||
|
||||
def test_cascades_to_images(self):
|
||||
pid = self._save("lora with image", category="lora-manager")
|
||||
self._link_image(pid, "/fake/path/image.jpg")
|
||||
|
||||
# Verify image is linked
|
||||
images = self.db.get_prompt_images(pid)
|
||||
self.assertEqual(len(images), 1)
|
||||
|
||||
self.db.delete_prompts_by_category("lora-manager")
|
||||
|
||||
# Prompt gone
|
||||
results = self.db.search_prompts(category="lora-manager")
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
def test_empty_category_string(self):
|
||||
self._save("test", category="general")
|
||||
deleted = self.db.delete_prompts_by_category("")
|
||||
self.assertEqual(deleted, 0)
|
||||
|
||||
|
||||
# ── Folder filter (search_prompts with folder param) ──────────────────
|
||||
|
||||
|
||||
class TestFolderFilter(LoraDBTestCase):
|
||||
"""Test search_prompts folder parameter for subfolder filtering."""
|
||||
|
||||
def _setup_prompts_with_images(self):
|
||||
"""Create prompts linked to images in different directories."""
|
||||
pid1 = self._save("landscape prompt", category="nature")
|
||||
self._link_image(pid1, "/output/landscapes/sunset.png")
|
||||
|
||||
pid2 = self._save("portrait prompt", category="portraits")
|
||||
self._link_image(pid2, "/output/portraits/face.png")
|
||||
|
||||
pid3 = self._save("another landscape", category="nature")
|
||||
self._link_image(pid3, "/output/landscapes/mountain.png")
|
||||
|
||||
return pid1, pid2, pid3
|
||||
|
||||
def test_filter_by_folder(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts(folder="landscapes")
|
||||
self.assertEqual(len(results), 2)
|
||||
texts = {r["text"] for r in results}
|
||||
self.assertEqual(texts, {"landscape prompt", "another landscape"})
|
||||
|
||||
def test_filter_different_folder(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts(folder="portraits")
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["text"], "portrait prompt")
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts(folder="nonexistent")
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
def test_no_folder_returns_all(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts()
|
||||
self.assertGreaterEqual(len(results), 3)
|
||||
|
||||
def test_folder_with_category_filter(self):
|
||||
self._setup_prompts_with_images()
|
||||
results = self.db.search_prompts(folder="landscapes", category="nature")
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
|
||||
# ── get_prompt_subfolders ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetPromptSubfolders(LoraDBTestCase):
|
||||
"""Test get_prompt_subfolders — extracts unique folder names from images."""
|
||||
|
||||
def test_extracts_subfolders(self):
|
||||
pid1 = self._save("prompt 1")
|
||||
self._link_image(pid1, "/output/folder_a/img1.png")
|
||||
|
||||
pid2 = self._save("prompt 2")
|
||||
self._link_image(pid2, "/output/folder_b/img2.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders()
|
||||
self.assertIsInstance(folders, list)
|
||||
self.assertGreaterEqual(len(folders), 2)
|
||||
|
||||
def test_deduplicates(self):
|
||||
pid1 = self._save("prompt 1")
|
||||
self._link_image(pid1, "/output/same_folder/img1.png")
|
||||
|
||||
pid2 = self._save("prompt 2")
|
||||
self._link_image(pid2, "/output/same_folder/img2.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders()
|
||||
# Count occurrences of the folder — should appear once
|
||||
matches = [f for f in folders if "same_folder" in f]
|
||||
self.assertEqual(len(matches), 1)
|
||||
|
||||
def test_empty_database(self):
|
||||
folders = self.db.get_prompt_subfolders()
|
||||
self.assertEqual(folders, [])
|
||||
|
||||
def test_returns_sorted(self):
|
||||
for i, name in enumerate(["charlie", "alpha", "bravo"]):
|
||||
pid = self._save(f"prompt {i}")
|
||||
self._link_image(pid, f"/output/{name}/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders()
|
||||
self.assertEqual(folders, sorted(folders))
|
||||
|
||||
def test_with_root_dirs(self):
|
||||
pid = self._save("prompt")
|
||||
self._link_image(pid, "/output/sub/deep/img.png")
|
||||
|
||||
folders = self.db.get_prompt_subfolders(root_dirs=["/output"])
|
||||
self.assertIsInstance(folders, list)
|
||||
self.assertGreater(len(folders), 0)
|
||||
|
||||
|
||||
# ── LoRA prompt workflow ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLoraPromptWorkflow(LoraDBTestCase):
|
||||
"""Test the full LoRA import workflow at the database layer."""
|
||||
|
||||
def test_save_lora_prompt_with_tags(self):
|
||||
"""Simulate what lora_scan does: save prompt with lora-manager tags."""
|
||||
pid = self._save(
|
||||
text="1girl, detailed face, anime style",
|
||||
category="lora-manager",
|
||||
tags=["lora-manager", "lora:my_lora", "trigger1"],
|
||||
)
|
||||
prompt = self.db.get_prompt_by_id(pid)
|
||||
self.assertEqual(prompt["category"], "lora-manager")
|
||||
self.assertIn("lora-manager", prompt["tags"])
|
||||
|
||||
def test_reimport_clears_and_recreates(self):
|
||||
"""Simulate reimport: delete old, create new."""
|
||||
# First import
|
||||
pid1 = self._save("old lora prompt", category="lora-manager")
|
||||
self._link_image(pid1, "/cache/old.jpg")
|
||||
|
||||
# Reimport
|
||||
self.db.delete_prompts_by_category("lora-manager")
|
||||
|
||||
# Second import
|
||||
pid2 = self._save("new lora prompt", category="lora-manager")
|
||||
self._link_image(pid2, "/cache/new.jpg")
|
||||
|
||||
results = self.db.search_prompts(category="lora-manager")
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["text"], "new lora prompt")
|
||||
|
||||
def test_hash_dedup_prevents_duplicates(self):
|
||||
"""Verify hash-based dedup works for LoRA prompts."""
|
||||
text = "duplicate lora prompt"
|
||||
h = generate_prompt_hash(text)
|
||||
|
||||
self._save(text, category="lora-manager")
|
||||
existing = self.db.get_prompt_by_hash(h)
|
||||
self.assertIsNotNone(existing)
|
||||
|
||||
def test_link_multiple_images_to_lora_prompt(self):
|
||||
"""LoRA prompts can have multiple preview images."""
|
||||
pid = self._save("multi-image lora", category="lora-manager")
|
||||
self._link_image(pid, "/cache/lora/img1.jpg")
|
||||
self._link_image(pid, "/cache/lora/img2.jpg")
|
||||
self._link_image(pid, "/cache/lora/img3.jpg")
|
||||
|
||||
images = self.db.get_prompt_images(pid)
|
||||
self.assertEqual(len(images), 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Unit tests for LoRA Manager integration utilities.
|
||||
|
||||
Tests metadata parsing, trigger word extraction, image URL extraction,
|
||||
directory detection, TriggerWordCache, and image download logic.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py.lora_utils import (
|
||||
TriggerWordCache,
|
||||
get_civitai_image_urls,
|
||||
get_example_prompt_from_metadata,
|
||||
get_lora_image_cache_dir,
|
||||
get_trigger_words_from_metadata,
|
||||
read_lora_metadata,
|
||||
)
|
||||
|
||||
# ── Sample metadata fixtures ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_metadata(
|
||||
trained_words=None,
|
||||
images=None,
|
||||
model_name="test_lora",
|
||||
file_name="test.safetensors",
|
||||
):
|
||||
"""Build a realistic LoRA metadata dict for testing."""
|
||||
meta = {"file_name": file_name}
|
||||
civitai = {}
|
||||
if trained_words is not None:
|
||||
civitai["trainedWords"] = trained_words
|
||||
if images is not None:
|
||||
civitai["images"] = images
|
||||
if model_name:
|
||||
civitai["model"] = {"name": model_name}
|
||||
if civitai:
|
||||
meta["civitai"] = civitai
|
||||
return meta
|
||||
|
||||
|
||||
# ── Pure function tests (no mocking) ──────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTriggerWords(unittest.TestCase):
|
||||
"""Test get_trigger_words_from_metadata — pure dict extraction."""
|
||||
|
||||
def test_extracts_words(self):
|
||||
meta = _make_metadata(trained_words=["word1", "word2", "word3"])
|
||||
self.assertEqual(
|
||||
get_trigger_words_from_metadata(meta), ["word1", "word2", "word3"]
|
||||
)
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
meta = _make_metadata(trained_words=[" padded ", "\ttabbed\t"])
|
||||
self.assertEqual(get_trigger_words_from_metadata(meta), ["padded", "tabbed"])
|
||||
|
||||
def test_filters_empty_strings(self):
|
||||
meta = _make_metadata(trained_words=["valid", "", " ", "also_valid"])
|
||||
self.assertEqual(get_trigger_words_from_metadata(meta), ["valid", "also_valid"])
|
||||
|
||||
def test_no_civitai_key(self):
|
||||
self.assertEqual(get_trigger_words_from_metadata({}), [])
|
||||
|
||||
def test_no_trained_words(self):
|
||||
meta = _make_metadata()
|
||||
self.assertEqual(get_trigger_words_from_metadata(meta), [])
|
||||
|
||||
def test_trained_words_not_list(self):
|
||||
meta = {"civitai": {"trainedWords": "not a list"}}
|
||||
self.assertEqual(get_trigger_words_from_metadata(meta), [])
|
||||
|
||||
def test_non_string_items_filtered(self):
|
||||
meta = _make_metadata(trained_words=["valid", 123, None, "also_valid"])
|
||||
self.assertEqual(get_trigger_words_from_metadata(meta), ["valid", "also_valid"])
|
||||
|
||||
|
||||
class TestGetExamplePrompt(unittest.TestCase):
|
||||
"""Test get_example_prompt_from_metadata — extracts first usable prompt."""
|
||||
|
||||
def test_extracts_first_prompt(self):
|
||||
images = [
|
||||
{"meta": {"prompt": "a beautiful landscape"}},
|
||||
{"meta": {"prompt": "second prompt"}},
|
||||
]
|
||||
meta = _make_metadata(images=images)
|
||||
self.assertEqual(
|
||||
get_example_prompt_from_metadata(meta), "a beautiful landscape"
|
||||
)
|
||||
|
||||
def test_skips_empty_prompts(self):
|
||||
images = [
|
||||
{"meta": {"prompt": ""}},
|
||||
{"meta": {"prompt": " "}},
|
||||
{"meta": {"prompt": "valid prompt"}},
|
||||
]
|
||||
meta = _make_metadata(images=images)
|
||||
self.assertEqual(get_example_prompt_from_metadata(meta), "valid prompt")
|
||||
|
||||
def test_no_images(self):
|
||||
meta = _make_metadata(images=[])
|
||||
self.assertIsNone(get_example_prompt_from_metadata(meta))
|
||||
|
||||
def test_no_civitai(self):
|
||||
self.assertIsNone(get_example_prompt_from_metadata({}))
|
||||
|
||||
def test_images_without_meta(self):
|
||||
images = [{"url": "http://example.com/img.jpg"}]
|
||||
meta = _make_metadata(images=images)
|
||||
self.assertIsNone(get_example_prompt_from_metadata(meta))
|
||||
|
||||
def test_meta_without_prompt(self):
|
||||
images = [{"meta": {"seed": 12345}}]
|
||||
meta = _make_metadata(images=images)
|
||||
self.assertIsNone(get_example_prompt_from_metadata(meta))
|
||||
|
||||
def test_non_dict_images_skipped(self):
|
||||
images = ["not a dict", None, {"meta": {"prompt": "found it"}}]
|
||||
meta = _make_metadata(images=images)
|
||||
self.assertEqual(get_example_prompt_from_metadata(meta), "found it")
|
||||
|
||||
def test_non_string_prompt_skipped(self):
|
||||
images = [{"meta": {"prompt": 12345}}, {"meta": {"prompt": "real prompt"}}]
|
||||
meta = _make_metadata(images=images)
|
||||
self.assertEqual(get_example_prompt_from_metadata(meta), "real prompt")
|
||||
|
||||
|
||||
class TestGetCivitaiImageUrls(unittest.TestCase):
|
||||
"""Test get_civitai_image_urls — extracts image URLs from metadata."""
|
||||
|
||||
def test_extracts_urls(self):
|
||||
images = [
|
||||
{"url": "https://civitai.com/img1.jpg"},
|
||||
{"url": "https://civitai.com/img2.jpg"},
|
||||
]
|
||||
meta = _make_metadata(images=images)
|
||||
urls = get_civitai_image_urls(meta)
|
||||
self.assertEqual(len(urls), 2)
|
||||
self.assertIn("https://civitai.com/img1.jpg", urls)
|
||||
|
||||
def test_filters_empty_urls(self):
|
||||
images = [{"url": ""}, {"url": "https://civitai.com/valid.jpg"}]
|
||||
meta = _make_metadata(images=images)
|
||||
urls = get_civitai_image_urls(meta)
|
||||
self.assertEqual(urls, ["https://civitai.com/valid.jpg"])
|
||||
|
||||
def test_no_images(self):
|
||||
meta = _make_metadata(images=[])
|
||||
self.assertEqual(get_civitai_image_urls(meta), [])
|
||||
|
||||
def test_no_civitai(self):
|
||||
self.assertEqual(get_civitai_image_urls({}), [])
|
||||
|
||||
def test_images_without_url_key(self):
|
||||
images = [{"id": 1}, {"url": "https://civitai.com/valid.jpg"}]
|
||||
meta = _make_metadata(images=images)
|
||||
urls = get_civitai_image_urls(meta)
|
||||
self.assertEqual(urls, ["https://civitai.com/valid.jpg"])
|
||||
|
||||
def test_non_dict_images_skipped(self):
|
||||
images = [None, "bad", {"url": "https://civitai.com/valid.jpg"}]
|
||||
meta = _make_metadata(images=images)
|
||||
urls = get_civitai_image_urls(meta)
|
||||
self.assertEqual(urls, ["https://civitai.com/valid.jpg"])
|
||||
|
||||
|
||||
# ── Filesystem-dependent tests ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestReadLoraMetadata(unittest.TestCase):
|
||||
"""Test read_lora_metadata — file I/O with JSON parsing."""
|
||||
|
||||
def test_reads_valid_json(self):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".metadata.json", delete=False
|
||||
) as f:
|
||||
json.dump({"civitai": {"trainedWords": ["test"]}}, f)
|
||||
f.flush()
|
||||
path = Path(f.name)
|
||||
try:
|
||||
result = read_lora_metadata(path)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["civitai"]["trainedWords"], ["test"])
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_returns_none_for_invalid_json(self):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".metadata.json", delete=False
|
||||
) as f:
|
||||
f.write("not valid json {{{")
|
||||
f.flush()
|
||||
path = Path(f.name)
|
||||
try:
|
||||
result = read_lora_metadata(path)
|
||||
self.assertIsNone(result)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_returns_none_for_missing_file(self):
|
||||
result = read_lora_metadata(Path("/nonexistent/file.metadata.json"))
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestGetLoraImageCacheDir(unittest.TestCase):
|
||||
"""Test get_lora_image_cache_dir — returns and creates cache path."""
|
||||
|
||||
def test_returns_path(self):
|
||||
cache_dir = get_lora_image_cache_dir()
|
||||
self.assertIsInstance(cache_dir, Path)
|
||||
self.assertTrue(str(cache_dir).endswith("data/lora_images"))
|
||||
|
||||
def test_directory_exists(self):
|
||||
cache_dir = get_lora_image_cache_dir()
|
||||
self.assertTrue(cache_dir.is_dir())
|
||||
|
||||
|
||||
# ── TriggerWordCache tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTriggerWordCache(unittest.TestCase):
|
||||
"""Test TriggerWordCache — thread-safe trigger word lookup."""
|
||||
|
||||
def setUp(self):
|
||||
self.cache = TriggerWordCache()
|
||||
|
||||
def test_initial_state(self):
|
||||
self.assertFalse(self.cache.is_loaded)
|
||||
self.assertEqual(self.cache.get_trigger_words("anything"), [])
|
||||
|
||||
def test_load_from_temp_directory(self):
|
||||
"""Create temp metadata files and verify cache loads them."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a metadata file
|
||||
meta = {
|
||||
"file_name": "my_lora.safetensors",
|
||||
"civitai": {"trainedWords": ["trigger1", "trigger2"]},
|
||||
}
|
||||
meta_path = Path(tmpdir) / "my_lora.safetensors.metadata.json"
|
||||
meta_path.write_text(json.dumps(meta))
|
||||
|
||||
# Patch find_lora_directories to return our temp dir
|
||||
with patch("py.lora_utils.find_lora_directories", return_value=[tmpdir]):
|
||||
count = self.cache.load(tmpdir)
|
||||
|
||||
self.assertTrue(self.cache.is_loaded)
|
||||
# Cache keys by both file_name stem and metadata filename stem
|
||||
self.assertGreaterEqual(count, 1)
|
||||
self.assertEqual(
|
||||
self.cache.get_trigger_words("my_lora"), ["trigger1", "trigger2"]
|
||||
)
|
||||
|
||||
def test_case_insensitive_lookup(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
meta = {
|
||||
"file_name": "MyLoRA.safetensors",
|
||||
"civitai": {"trainedWords": ["word1"]},
|
||||
}
|
||||
(Path(tmpdir) / "MyLoRA.safetensors.metadata.json").write_text(
|
||||
json.dumps(meta)
|
||||
)
|
||||
|
||||
with patch("py.lora_utils.find_lora_directories", return_value=[tmpdir]):
|
||||
self.cache.load(tmpdir)
|
||||
|
||||
self.assertEqual(self.cache.get_trigger_words("mylora"), ["word1"])
|
||||
self.assertEqual(self.cache.get_trigger_words("MYLORA"), ["word1"])
|
||||
|
||||
def test_clear(self):
|
||||
# Manually set cache state
|
||||
self.cache._cache = {"test": ["word"]}
|
||||
self.cache._loaded = True
|
||||
|
||||
self.cache.clear()
|
||||
self.assertFalse(self.cache.is_loaded)
|
||||
self.assertEqual(self.cache.get_trigger_words("test"), [])
|
||||
|
||||
def test_unknown_lora_returns_empty(self):
|
||||
self.cache._cache = {"known": ["word"]}
|
||||
self.cache._loaded = True
|
||||
self.assertEqual(self.cache.get_trigger_words("unknown"), [])
|
||||
|
||||
def test_thread_safety(self):
|
||||
"""Verify concurrent access doesn't raise."""
|
||||
self.cache._cache = {"lora": ["word"]}
|
||||
self.cache._loaded = True
|
||||
|
||||
errors = []
|
||||
|
||||
def reader():
|
||||
try:
|
||||
for _ in range(100):
|
||||
self.cache.get_trigger_words("lora")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=reader) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -34,6 +34,8 @@ def _make_tracker(prompt_timeout=600, cleanup_interval=300):
|
||||
tracker._local = threading.local()
|
||||
tracker.active_prompts = {}
|
||||
tracker.lock = threading.Lock()
|
||||
tracker._prompt_queue = []
|
||||
tracker._queue_lock = threading.Lock()
|
||||
tracker.cleanup_interval = cleanup_interval
|
||||
tracker.prompt_timeout = prompt_timeout
|
||||
# Don't start cleanup thread in tests
|
||||
@@ -256,5 +258,59 @@ class TestCleanupExpiredPrompts(unittest.TestCase):
|
||||
self.assertEqual(len(expired_ids), 1)
|
||||
|
||||
|
||||
class TestPromptQueue(unittest.TestCase):
|
||||
"""Test the FIFO prompt queue for batch workflow image linking."""
|
||||
|
||||
def setUp(self):
|
||||
self.tracker = _make_tracker()
|
||||
|
||||
def test_queue_empty_initially(self):
|
||||
result = self.tracker.pop_next_prompt()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_set_prompt_pushes_to_queue(self):
|
||||
self.tracker.set_current_prompt("p1", {"prompt_id": 10})
|
||||
self.assertEqual(len(self.tracker._prompt_queue), 1)
|
||||
|
||||
def test_pop_returns_fifo_order(self):
|
||||
self.tracker.set_current_prompt("first", {"prompt_id": 1})
|
||||
self.tracker.set_current_prompt("second", {"prompt_id": 2})
|
||||
self.tracker.set_current_prompt("third", {"prompt_id": 3})
|
||||
|
||||
p1 = self.tracker.pop_next_prompt()
|
||||
p2 = self.tracker.pop_next_prompt()
|
||||
p3 = self.tracker.pop_next_prompt()
|
||||
|
||||
self.assertEqual(p1["id"], 1)
|
||||
self.assertEqual(p2["id"], 2)
|
||||
self.assertEqual(p3["id"], 3)
|
||||
|
||||
def test_pop_returns_none_when_exhausted(self):
|
||||
self.tracker.set_current_prompt("only", {"prompt_id": 1})
|
||||
self.tracker.pop_next_prompt()
|
||||
result = self.tracker.pop_next_prompt()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_queue_preserves_prompt_text(self):
|
||||
self.tracker.set_current_prompt("hello world", {"prompt_id": 5})
|
||||
ctx = self.tracker.pop_next_prompt()
|
||||
self.assertEqual(ctx["text"], "hello world")
|
||||
|
||||
def test_batch_simulation(self):
|
||||
"""Simulate a batch workflow: encode 10 prompts, then pop 10 in order."""
|
||||
ids = list(range(100, 110))
|
||||
for pid in ids:
|
||||
self.tracker.set_current_prompt(f"prompt_{pid}", {"prompt_id": pid})
|
||||
|
||||
popped_ids = []
|
||||
while True:
|
||||
ctx = self.tracker.pop_next_prompt()
|
||||
if ctx is None:
|
||||
break
|
||||
popped_ids.append(ctx["id"])
|
||||
|
||||
self.assertEqual(popped_ids, ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Tests for PromptSearchList output filtering.
|
||||
|
||||
Tests the newline collapsing, multi-part (Clip_) filtering,
|
||||
and LoRA-only filtering applied to search results.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
# --- Helpers: replicate the filter logic from prompt_search_list.py ---
|
||||
|
||||
LORA_ONLY = re.compile(r"^(\s*<lora:[^>]+>\s*)+$")
|
||||
|
||||
|
||||
def apply_filters(texts, skip_multipart=True):
|
||||
"""Apply the same filtering chain as PromptSearchList.search()."""
|
||||
# Collapse newlines
|
||||
result = [" ".join(t.split()) for t in texts if t]
|
||||
|
||||
# Filter multi-part
|
||||
if skip_multipart:
|
||||
result = [p for p in result if not re.search(r"Clip_\d+", p)]
|
||||
|
||||
# Filter LoRA-only
|
||||
result = [p for p in result if not LORA_ONLY.match(p)]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TestNewlineCollapse(unittest.TestCase):
|
||||
"""Newlines must be collapsed so StringOutputList treats each DB entry as one prompt."""
|
||||
|
||||
def test_single_line_unchanged(self):
|
||||
result = apply_filters(["a beautiful landscape"])
|
||||
self.assertEqual(result, ["a beautiful landscape"])
|
||||
|
||||
def test_multiline_collapsed_to_spaces(self):
|
||||
result = apply_filters(["line one\nline two\nline three"])
|
||||
self.assertEqual(result, ["line one line two line three"])
|
||||
|
||||
def test_tabs_and_extra_spaces_collapsed(self):
|
||||
result = apply_filters(["word1 \t word2\n\nword3"])
|
||||
self.assertEqual(result, ["word1 word2 word3"])
|
||||
|
||||
def test_leading_trailing_whitespace_stripped(self):
|
||||
result = apply_filters([" padded prompt \n"])
|
||||
self.assertEqual(result, ["padded prompt"])
|
||||
|
||||
|
||||
class TestMultipartFilter(unittest.TestCase):
|
||||
"""Prompts containing Clip_N markers should be filtered when skip_multipart=True."""
|
||||
|
||||
def test_clip_markers_filtered(self):
|
||||
texts = [
|
||||
"Clip_1 a video prompt Clip_2 continuation",
|
||||
"a normal prompt",
|
||||
]
|
||||
result = apply_filters(texts, skip_multipart=True)
|
||||
self.assertEqual(result, ["a normal prompt"])
|
||||
|
||||
def test_clip_markers_kept_when_disabled(self):
|
||||
texts = ["Clip_1 a video prompt Clip_2 continuation"]
|
||||
result = apply_filters(texts, skip_multipart=False)
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
def test_clip_in_word_filtered(self):
|
||||
# "Clip_3" anywhere in text should match
|
||||
texts = ["some text with Clip_3 marker"]
|
||||
result = apply_filters(texts, skip_multipart=True)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_clip_without_number_not_filtered(self):
|
||||
texts = ["clip art style painting"]
|
||||
result = apply_filters(texts, skip_multipart=True)
|
||||
self.assertEqual(result, ["clip art style painting"])
|
||||
|
||||
|
||||
class TestLoraOnlyFilter(unittest.TestCase):
|
||||
"""Prompts that are only LoRA tags with no actual content should be filtered."""
|
||||
|
||||
def test_single_lora_tag_filtered(self):
|
||||
result = apply_filters(["<lora:some_model:0.7>"])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_multiple_lora_tags_filtered(self):
|
||||
result = apply_filters(["<lora:model_a:1> <lora:model_b:0.5>"])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_lora_with_content_kept(self):
|
||||
result = apply_filters(["<lora:style:0.8> a beautiful painting"])
|
||||
self.assertEqual(result, ["<lora:style:0.8> a beautiful painting"])
|
||||
|
||||
def test_content_with_lora_in_middle_kept(self):
|
||||
result = apply_filters(["masterpiece, <lora:fix:1>, best quality"])
|
||||
self.assertEqual(result, ["masterpiece, <lora:fix:1>, best quality"])
|
||||
|
||||
def test_lora_with_newlines_collapsed_then_filtered(self):
|
||||
# After collapsing newlines, this becomes a single line of LoRA tags
|
||||
result = apply_filters(["<lora:a:1>\n<lora:b:1>"])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_empty_string_filtered(self):
|
||||
result = apply_filters([""])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
class TestFilterCombination(unittest.TestCase):
|
||||
"""Test all filters working together on a mixed batch."""
|
||||
|
||||
def test_mixed_batch(self):
|
||||
texts = [
|
||||
"a good prompt",
|
||||
"<lora:style:1>",
|
||||
"Clip_1\nfirst segment\nClip_2\nsecond segment",
|
||||
"<lora:model:0.5> nice painting, flowers",
|
||||
"another\ngood\nprompt",
|
||||
"<lora:a:1> <lora:b:1>",
|
||||
]
|
||||
result = apply_filters(texts)
|
||||
self.assertEqual(
|
||||
result,
|
||||
[
|
||||
"a good prompt",
|
||||
"<lora:model:0.5> nice painting, flowers",
|
||||
"another good prompt",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+151
-32
@@ -80,14 +80,26 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
directory. It filters for image files and schedules them for processing after
|
||||
a small delay to ensure the file is fully written.
|
||||
|
||||
The prompt context is captured immediately (before the delay) to handle
|
||||
batch workflows where the prompt tracker advances before images are processed.
|
||||
|
||||
Args:
|
||||
event: FileSystemEvent object containing event details
|
||||
"""
|
||||
if not event.is_directory and self.is_image_file(event.src_path):
|
||||
self.logger.info(f"New image detected: {event.src_path}")
|
||||
# Small delay to ensure file is fully written
|
||||
# Snapshot prompt context NOW before the delay — in batch workflows
|
||||
# the tracker advances to the next prompt before images are processed.
|
||||
prompt_snapshot = self.prompt_tracker.get_current_prompt()
|
||||
if prompt_snapshot:
|
||||
self.logger.debug(
|
||||
f"Snapshot prompt {prompt_snapshot.get('id', '?')} for {os.path.basename(event.src_path)}"
|
||||
)
|
||||
threading.Timer(
|
||||
self.processing_delay, self.process_new_image, args=[event.src_path]
|
||||
self.processing_delay,
|
||||
self.process_new_image,
|
||||
args=[event.src_path],
|
||||
kwargs={"prompt_snapshot": prompt_snapshot},
|
||||
).start()
|
||||
|
||||
def is_image_file(self, filepath: str) -> bool:
|
||||
@@ -105,18 +117,19 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
return False
|
||||
return filepath.lower().endswith(self.supported_extensions)
|
||||
|
||||
def process_new_image(self, image_path: str):
|
||||
def process_new_image(self, image_path: str, prompt_snapshot=None):
|
||||
"""Process a newly created image file for gallery integration.
|
||||
|
||||
This method handles the complete processing pipeline for a new image:
|
||||
1. Verifies the file still exists
|
||||
2. Gets the current prompt context from the tracker
|
||||
3. Extracts ComfyUI metadata from the image
|
||||
4. Links the image to the appropriate prompt in the database
|
||||
5. Handles fallback scenarios when no active prompt is available
|
||||
2. Extracts ComfyUI metadata from the image
|
||||
3. Tries to identify the correct prompt via metadata-based lookup
|
||||
4. Falls back to the prompt snapshot captured at event time
|
||||
5. Links the image to the appropriate prompt in the database
|
||||
|
||||
Args:
|
||||
image_path: Full path to the newly created image file
|
||||
prompt_snapshot: Prompt context captured at file-creation time (optional)
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Processing image: {image_path}")
|
||||
@@ -125,35 +138,61 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
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.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}")
|
||||
# Fallback: try to link to the most recent prompt in database
|
||||
current_prompt = self._get_fallback_prompt()
|
||||
if current_prompt:
|
||||
self.logger.debug(f"Using fallback prompt: {current_prompt['id']}")
|
||||
else:
|
||||
self.logger.warning(f"No fallback prompt available, skipping image")
|
||||
return
|
||||
else:
|
||||
# Extend the timeout for this prompt since we're still getting images
|
||||
if "execution_id" in current_prompt:
|
||||
self.prompt_tracker.extend_prompt_timeout(
|
||||
current_prompt["execution_id"], 300
|
||||
) # Add 5 more minutes
|
||||
|
||||
# Extract ComfyUI metadata
|
||||
# Extract ComfyUI metadata first — needed both for linking and prompt lookup
|
||||
metadata = None
|
||||
try:
|
||||
metadata = self.metadata_extractor.extract_metadata(image_path)
|
||||
self.logger.debug(f"Extracted metadata: {bool(metadata)}")
|
||||
except Exception as meta_error:
|
||||
self.logger.warning(f"Metadata extraction failed: {meta_error}")
|
||||
metadata = None
|
||||
|
||||
# Strategy 1: Pop from batch queue (most reliable for batch workflows).
|
||||
# Prompts are queued during CLIP encoding in order; images save in
|
||||
# the same order, so FIFO pop gives the correct prompt per image.
|
||||
current_prompt = self.prompt_tracker.pop_next_prompt()
|
||||
if current_prompt:
|
||||
self.logger.info(
|
||||
f"Queue match: prompt {current_prompt['id']} for "
|
||||
f"{os.path.basename(image_path)}"
|
||||
)
|
||||
|
||||
# Strategy 2: Find prompt from image metadata
|
||||
if not current_prompt:
|
||||
current_prompt = self._find_prompt_from_metadata(metadata)
|
||||
if current_prompt:
|
||||
self.logger.info(f"Metadata match: prompt {current_prompt['id']}")
|
||||
|
||||
# Strategy 3: Use snapshot captured at file-creation time
|
||||
if not current_prompt and prompt_snapshot:
|
||||
current_prompt = prompt_snapshot
|
||||
self.logger.info(
|
||||
f"Snapshot match: prompt {current_prompt.get('id', 'unknown')}"
|
||||
)
|
||||
|
||||
# Strategy 4: Check live prompt tracker
|
||||
if not current_prompt:
|
||||
current_prompt = self.prompt_tracker.get_current_prompt()
|
||||
if current_prompt:
|
||||
self.logger.info(
|
||||
f"Live tracker match: prompt {current_prompt['id']}"
|
||||
)
|
||||
|
||||
# Strategy 5: Fallback to most recent prompt in DB
|
||||
if not current_prompt:
|
||||
current_prompt = self._get_fallback_prompt()
|
||||
if current_prompt:
|
||||
self.logger.debug(f"Fallback match: prompt {current_prompt['id']}")
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"No prompt context available, skipping image: {image_path}"
|
||||
)
|
||||
return
|
||||
|
||||
# Extend timeout if we have an active execution
|
||||
if current_prompt.get("execution_id"):
|
||||
self.prompt_tracker.extend_prompt_timeout(
|
||||
current_prompt["execution_id"], 300
|
||||
)
|
||||
|
||||
if metadata:
|
||||
self.logger.debug(
|
||||
@@ -164,7 +203,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
self.logger.debug(
|
||||
f"Linking image with basic info to prompt {current_prompt['id']}"
|
||||
)
|
||||
# Link with basic file info even without metadata
|
||||
basic_metadata = self.get_basic_file_info(image_path)
|
||||
self.link_image_to_prompt(
|
||||
image_path, current_prompt, {"file_info": basic_metadata}
|
||||
@@ -176,6 +214,87 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
|
||||
self.logger.error(traceback.format_exc())
|
||||
|
||||
def _find_prompt_from_metadata(self, metadata):
|
||||
"""Extract prompt text from image metadata and look up the matching DB prompt.
|
||||
|
||||
Parses the ComfyUI workflow/prompt data embedded in the image to find
|
||||
PromptManager node inputs, then matches against the database by hash.
|
||||
|
||||
Args:
|
||||
metadata: Extracted metadata dict from the image, or None
|
||||
|
||||
Returns:
|
||||
Prompt context dict with 'id' and 'text', or None if not found
|
||||
"""
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
prompt_text = None
|
||||
|
||||
# Try to find PromptManager node in the prompt execution data
|
||||
prompt_data = metadata.get("prompt")
|
||||
if isinstance(prompt_data, dict):
|
||||
for node_id, node_info in prompt_data.items():
|
||||
if not isinstance(node_info, dict):
|
||||
continue
|
||||
class_type = node_info.get("class_type", "")
|
||||
if class_type in ("PromptManager", "PromptManagerText"):
|
||||
inputs = node_info.get("inputs", {})
|
||||
text = inputs.get("text", "")
|
||||
if text and isinstance(text, str) and text.strip():
|
||||
prompt_text = text.strip()
|
||||
break
|
||||
|
||||
# Fallback: check text_encoder_nodes from workflow, but only if
|
||||
# the text input is NOT connected (connected inputs override widget values,
|
||||
# so the widget value would be stale in batch workflows).
|
||||
if not prompt_text:
|
||||
text_nodes = metadata.get("text_encoder_nodes", [])
|
||||
for node in text_nodes:
|
||||
node_type = node.get("type") or node.get("class_type") or ""
|
||||
if "PromptManager" in node_type:
|
||||
# Check if text input is connected — if so, widget value is stale
|
||||
text_connected = False
|
||||
for inp in node.get("inputs", []):
|
||||
if isinstance(inp, dict) and inp.get("name") == "text":
|
||||
if inp.get("link") is not None:
|
||||
text_connected = True
|
||||
break
|
||||
if text_connected:
|
||||
self.logger.debug(
|
||||
"PromptManager text input is connected — "
|
||||
"skipping stale widget value, deferring to snapshot"
|
||||
)
|
||||
continue
|
||||
widgets = node.get("widgets_values", [])
|
||||
if widgets and isinstance(widgets[0], str) and widgets[0].strip():
|
||||
prompt_text = widgets[0].strip()
|
||||
break
|
||||
|
||||
if not prompt_text:
|
||||
return None
|
||||
|
||||
# Look up by hash in database
|
||||
try:
|
||||
import hashlib
|
||||
|
||||
normalized = prompt_text.strip().lower()
|
||||
prompt_hash = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
existing = self.db_manager.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
self.logger.debug(
|
||||
f"Found DB prompt {existing['id']} from metadata text"
|
||||
)
|
||||
return {
|
||||
"id": existing["id"],
|
||||
"text": existing["text"],
|
||||
"from_metadata": True,
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Metadata-based prompt lookup failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_basic_file_info(self, image_path: str) -> Dict[str, Any]:
|
||||
"""Get basic file information when metadata extraction fails.
|
||||
|
||||
|
||||
@@ -76,6 +76,10 @@ class PromptTracker:
|
||||
self._local = threading.local()
|
||||
self.active_prompts = {} # Global tracking for multiple threads
|
||||
self.lock = threading.Lock()
|
||||
# FIFO queue for batch workflows: prompt_ids are pushed during encoding
|
||||
# and popped during image linking, preserving correct ordering.
|
||||
self._prompt_queue = []
|
||||
self._queue_lock = threading.Lock()
|
||||
|
||||
# Read from GalleryConfig if available, otherwise use defaults
|
||||
try:
|
||||
@@ -159,6 +163,13 @@ class PromptTracker:
|
||||
with self.lock:
|
||||
self.active_prompts[execution_id] = execution_context
|
||||
|
||||
# Push to batch queue for ordered image linking
|
||||
with self._queue_lock:
|
||||
self._prompt_queue.append(execution_context)
|
||||
self.logger.debug(
|
||||
f"Queue size: {len(self._prompt_queue)} after push for prompt {prompt_id}"
|
||||
)
|
||||
|
||||
self.logger.debug(
|
||||
f"Set current prompt: {execution_id} -> {prompt_text[:50]}... (thread: {threading.current_thread().ident})"
|
||||
)
|
||||
@@ -207,6 +218,26 @@ class PromptTracker:
|
||||
)
|
||||
return None
|
||||
|
||||
def pop_next_prompt(self) -> Optional[Dict[str, Any]]:
|
||||
"""Pop the next prompt from the batch queue (FIFO).
|
||||
|
||||
In batch workflows, prompts are encoded in order and images are saved
|
||||
in the same order. This method pops the oldest queued prompt, ensuring
|
||||
each image links to the correct prompt by position.
|
||||
|
||||
Returns:
|
||||
Prompt context dict, or None if queue is empty
|
||||
"""
|
||||
with self._queue_lock:
|
||||
if self._prompt_queue:
|
||||
ctx = self._prompt_queue.pop(0)
|
||||
self.logger.debug(
|
||||
f"Queue pop: prompt {ctx.get('id')} "
|
||||
f"({len(self._prompt_queue)} remaining)"
|
||||
)
|
||||
return ctx
|
||||
return None
|
||||
|
||||
def _find_recent_prompt(self) -> Optional[Dict[str, Any]]:
|
||||
"""Find the most recent prompt that's still valid.
|
||||
|
||||
|
||||
@@ -406,6 +406,56 @@
|
||||
<p class="text-xs text-pm-muted mt-1">Choose how the Web UI opens from ComfyUI nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Integrations Section -->
|
||||
<div class="border-b border-pm pb-3">
|
||||
<h4 class="text-xs font-semibold text-pm-secondary mb-3 uppercase tracking-wide">Integrations</h4>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="block text-xs font-medium text-pm-secondary">LoraManager</label>
|
||||
<span id="loraDetectionBadge" class="text-[10px] px-1.5 py-0.5 rounded-full bg-pm-input text-pm-muted">checking...</span>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="loraEnabled" class="sr-only peer" disabled>
|
||||
<div class="w-8 h-4 bg-pm-input rounded-full peer peer-checked:bg-pm-accent transition-colors pointer-events-none after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:after:translate-x-4"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="loraSettings" class="hidden space-y-2 mt-2 pl-2 border-l-2 border-pm">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-pm-secondary mb-1">Path</label>
|
||||
<input type="text" id="loraManagerPath" readonly
|
||||
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] text-pm-muted">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-xs font-medium text-pm-secondary">Auto-inject trigger words</label>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="loraTriggerWords" class="sr-only peer">
|
||||
<div class="w-8 h-4 bg-pm-input rounded-full peer peer-checked:bg-pm-accent transition-colors pointer-events-none after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:after:translate-x-4"></div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-pm-muted">Automatically append trigger words when <code class="text-pm-accent"><lora:name:weight></code> is detected in prompts.</p>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="block text-xs font-medium text-pm-secondary mb-1">CivitAI API Key</label>
|
||||
<input type="password" id="civitaiApiKey" placeholder="Optional — required for NSFW example images"
|
||||
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] placeholder:text-pm-muted focus:outline-none">
|
||||
<p class="text-xs text-pm-muted mt-1">Get your key from <a href="https://civitai.com/user/account" target="_blank" class="text-pm-accent hover:underline">civitai.com/user/account</a></p>
|
||||
</div>
|
||||
|
||||
<button id="loraImportBtn" type="button"
|
||||
class="mt-1 flex items-center gap-1 text-xs text-pm-accent hover:text-pm-accent-hover">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
Import LoRA Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
@@ -421,6 +471,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LoRA Import Progress Modal -->
|
||||
<div id="loraImportModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
|
||||
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-md w-full mx-4 border border-pm shadow-pm">
|
||||
<h3 class="text-sm font-semibold text-pm mb-4">Importing LoRA Data</h3>
|
||||
|
||||
<div class="mb-3">
|
||||
<p class="text-pm-secondary text-xs">Status:</p>
|
||||
<p id="loraImportStatus" class="text-pm text-xs font-medium truncate">Initializing...</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between text-xs text-pm-secondary mb-1">
|
||||
<span>Progress</span>
|
||||
<span id="loraImportPercent">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-pm-input rounded-full h-1.5">
|
||||
<div id="loraImportBar" class="bg-pm-accent h-1.5 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-[11px] text-pm-muted mt-1">
|
||||
<span id="loraImportProcessed">0 processed</span>
|
||||
<span id="loraImportImported">0 imported</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Tag Modal -->
|
||||
<div id="bulkTagModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
|
||||
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-md w-full mx-4 border border-pm shadow-pm">
|
||||
|
||||
+165
@@ -755,6 +755,7 @@
|
||||
document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode;
|
||||
this.renderScanPaths();
|
||||
this.updateMonitoringStatus();
|
||||
this.detectLoraManager();
|
||||
this.showModal("settingsModal");
|
||||
}
|
||||
|
||||
@@ -776,6 +777,166 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── LoraManager integration ──────────────────────────────
|
||||
|
||||
async detectLoraManager() {
|
||||
const badge = document.getElementById("loraDetectionBadge");
|
||||
const toggle = document.getElementById("loraEnabled");
|
||||
const settings = document.getElementById("loraSettings");
|
||||
|
||||
try {
|
||||
// First check current status
|
||||
const statusRes = await fetch("/prompt_manager/lora/status");
|
||||
if (statusRes.ok) {
|
||||
const status = await statusRes.json();
|
||||
if (status.success && status.enabled) {
|
||||
badge.textContent = "enabled";
|
||||
badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/20 text-green-400";
|
||||
toggle.checked = true;
|
||||
toggle.disabled = false;
|
||||
document.getElementById("loraManagerPath").value = status.path;
|
||||
document.getElementById("loraTriggerWords").checked = status.trigger_words_enabled;
|
||||
document.getElementById("civitaiApiKey").value = status.civitai_api_key || "";
|
||||
settings.classList.remove("hidden");
|
||||
this._loraPath = status.path;
|
||||
this._bindLoraEvents();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try auto-detection
|
||||
const detectRes = await fetch("/prompt_manager/lora/detect");
|
||||
if (!detectRes.ok) return;
|
||||
const data = await detectRes.json();
|
||||
|
||||
if (data.detected) {
|
||||
badge.textContent = "detected";
|
||||
badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/20 text-blue-400";
|
||||
toggle.disabled = false;
|
||||
document.getElementById("loraManagerPath").value = data.path;
|
||||
this._loraPath = data.path;
|
||||
} else {
|
||||
badge.textContent = "not installed";
|
||||
badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-pm-input text-pm-muted";
|
||||
toggle.disabled = true;
|
||||
}
|
||||
|
||||
this._bindLoraEvents();
|
||||
} catch (e) {
|
||||
badge.textContent = "error";
|
||||
badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/20 text-red-400";
|
||||
}
|
||||
}
|
||||
|
||||
_bindLoraEvents() {
|
||||
if (this._loraBound) return;
|
||||
this._loraBound = true;
|
||||
|
||||
const toggle = document.getElementById("loraEnabled");
|
||||
const settings = document.getElementById("loraSettings");
|
||||
|
||||
toggle.addEventListener("change", () => {
|
||||
if (toggle.checked) {
|
||||
settings.classList.remove("hidden");
|
||||
} else {
|
||||
settings.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("loraImportBtn").addEventListener("click", () => {
|
||||
this.runLoraImport();
|
||||
});
|
||||
}
|
||||
|
||||
async saveLoraSettings() {
|
||||
const enabled = document.getElementById("loraEnabled").checked;
|
||||
const triggerWords = document.getElementById("loraTriggerWords").checked;
|
||||
const civitaiKey = document.getElementById("civitaiApiKey").value.trim();
|
||||
const path = this._loraPath || "";
|
||||
|
||||
try {
|
||||
const res = await fetch("/prompt_manager/lora/enable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
path,
|
||||
trigger_words_enabled: triggerWords,
|
||||
civitai_api_key: civitaiKey,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.showNotification(data.error || "Failed to save LoRA settings", "error");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.showNotification("Failed to save LoRA settings", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async runLoraImport() {
|
||||
const saved = await this.saveLoraSettings();
|
||||
if (!saved) return;
|
||||
|
||||
// Close settings and show progress modal
|
||||
this.hideModal("settingsModal");
|
||||
document.getElementById("loraImportStatus").textContent = "Initializing...";
|
||||
document.getElementById("loraImportBar").style.width = "0%";
|
||||
document.getElementById("loraImportPercent").textContent = "0%";
|
||||
document.getElementById("loraImportProcessed").textContent = "0 processed";
|
||||
document.getElementById("loraImportImported").textContent = "0 imported";
|
||||
this.showModal("loraImportModal");
|
||||
|
||||
try {
|
||||
const res = await fetch("/prompt_manager/lora/scan", { method: "POST" });
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const pct = data.progress || 0;
|
||||
document.getElementById("loraImportBar").style.width = `${pct}%`;
|
||||
document.getElementById("loraImportPercent").textContent = `${pct}%`;
|
||||
|
||||
if (data.status) {
|
||||
document.getElementById("loraImportStatus").textContent = data.status;
|
||||
}
|
||||
if (data.processed !== undefined) {
|
||||
document.getElementById("loraImportProcessed").textContent = `${data.processed} processed`;
|
||||
}
|
||||
if (data.imported !== undefined) {
|
||||
document.getElementById("loraImportImported").textContent = `${data.imported} imported`;
|
||||
}
|
||||
|
||||
if (data.type === "complete") {
|
||||
document.getElementById("loraImportStatus").textContent =
|
||||
`Done — ${data.imported} imported, ${data.skipped} skipped`;
|
||||
this.loadStatistics();
|
||||
setTimeout(() => this.hideModal("loraImportModal"), 2000);
|
||||
}
|
||||
} catch (e) { /* skip malformed SSE */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById("loraImportStatus").textContent = `Error: ${e.message}`;
|
||||
setTimeout(() => this.hideModal("loraImportModal"), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const timeout = parseInt(document.getElementById("resultTimeout").value);
|
||||
const displayMode = document.getElementById("webuiDisplayMode").value;
|
||||
@@ -798,6 +959,10 @@
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Save LoRA integration settings (fire-and-forget)
|
||||
await this.saveLoraSettings();
|
||||
|
||||
if (data.restart_required) {
|
||||
this.showNotification("Settings saved. Restart ComfyUI for gallery path changes to take effect.", "warning");
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user