Merge pull request #1 from danieljanata/claude/comfy-prompt-manager-node-orsi9
Add complete ComfyUI Prompting System implementation
This commit is contained in:
@@ -1 +1,579 @@
|
||||
# ComfyUI-Prompting-System
|
||||
# 🎨 ComfyUI Prompting System
|
||||
|
||||
A comprehensive prompt management system for ComfyUI with database storage, thumbnail management, and advanced features.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **💾 Prompt Saver** - Save prompts with metadata (category, tags, rating, notes)
|
||||
- **🔒 Lock/Unlock Mechanism** - Control when prompts overwrite from database
|
||||
- **✏️ Smart Text Input** - Automatic change detection (new vs edit)
|
||||
- **📚 Library Browser** - Browse, search, filter prompts with rich UI
|
||||
- **🖼️ Thumbnail Management** - Store up to 3 thumbnails per prompt with lock/unlock
|
||||
- **📤 Export/Import** - Export database as JSON, merge with other databases
|
||||
- **🧹 Auto-Cleanup** - Automatically remove old unrated prompts
|
||||
- **🔍 Advanced Search** - Search by text, category, tags, rating
|
||||
- **📊 Statistics** - Track usage, ratings, generations
|
||||
- **🌐 Cross-Platform** - Works on Windows, Linux, macOS
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Method 1: Git Clone (Recommended)
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/danieljanata/ComfyUI-Prompting-System.git
|
||||
```
|
||||
|
||||
### Method 2: Manual Download
|
||||
|
||||
1. Download ZIP from GitHub
|
||||
2. Extract to `ComfyUI/custom_nodes/ComfyUI-Prompting-System`
|
||||
3. Restart ComfyUI
|
||||
|
||||
### Requirements
|
||||
|
||||
- ComfyUI (latest version)
|
||||
- Python 3.8+
|
||||
- PIL/Pillow (usually included with ComfyUI)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
1. **Add Smart Text Input node**
|
||||
- Type your prompt
|
||||
- Detects if it's a new prompt or edit
|
||||
|
||||
2. **Connect to Prompt Saver node**
|
||||
- Select category (or create new)
|
||||
- Add tags (comma-separated)
|
||||
- Rate the prompt (0-5 stars)
|
||||
- Add notes (optional)
|
||||
|
||||
3. **Lock/Unlock behavior**
|
||||
- 🔓 **Unlocked**: Loads latest prompt from category
|
||||
- 🔒 **Locked**: Saves current prompt
|
||||
|
||||
4. **Browse saved prompts**
|
||||
- Add Prompt Library Browser node
|
||||
- Search, filter, load prompts
|
||||
- View thumbnails and generation history
|
||||
|
||||
## 📋 Node Documentation
|
||||
|
||||
### 💾 Prompt Saver
|
||||
|
||||
Saves prompts with full metadata and lock/unlock mechanism.
|
||||
|
||||
**Inputs:**
|
||||
- `text` (STRING) - Prompt text
|
||||
- `locked` (BOOLEAN) - Lock/unlock toggle
|
||||
- `category` (DROPDOWN) - Prompt category
|
||||
- `new_category` (STRING) - Create new category
|
||||
- `tags` (STRING) - Comma-separated tags
|
||||
- `rating` (INT 0-5) - Prompt quality rating
|
||||
- `notes` (STRING) - Additional notes
|
||||
- `auto_save` (BOOLEAN) - Enable/disable auto-save
|
||||
|
||||
**Outputs:**
|
||||
- `text` - Processed text
|
||||
- `category` - Selected category
|
||||
- `prompt_id` - Database ID of saved prompt
|
||||
|
||||
**Behavior:**
|
||||
|
||||
**Unlocked Mode (🔓):**
|
||||
- Loads latest prompt from selected category
|
||||
- Overwrites text input with database content
|
||||
- Useful for reusing previous prompts
|
||||
|
||||
**Locked Mode (🔒):**
|
||||
- Saves current text to database
|
||||
- Detects if new prompt or update (based on similarity)
|
||||
- Creates new entry if text is significantly different
|
||||
- Updates existing if minor changes
|
||||
|
||||
**Pulsing Button:**
|
||||
- The unlock button pulses to remind you it's in overwrite mode
|
||||
- Lock it when you want to save new content
|
||||
|
||||
---
|
||||
|
||||
### ✏️ Smart Text Input
|
||||
|
||||
Enhanced text input with automatic change detection.
|
||||
|
||||
**Inputs:**
|
||||
- `text` (STRING) - Your prompt text
|
||||
- `similarity_threshold` (FLOAT 0-1) - Threshold for new vs edit detection
|
||||
|
||||
**Outputs:**
|
||||
- `text` - The input text
|
||||
- `is_new_prompt` (BOOLEAN) - True if completely new
|
||||
- `similarity` (FLOAT) - Similarity score with previous text
|
||||
|
||||
**Usage:**
|
||||
- Type your prompt normally
|
||||
- Node automatically detects if you're writing something new or editing
|
||||
- Visual feedback (color change) indicates detection result
|
||||
|
||||
---
|
||||
|
||||
### 📚 Prompt Library Browser
|
||||
|
||||
Interactive browser for managing your prompt library.
|
||||
|
||||
**Inputs:**
|
||||
- `action` (DROPDOWN) - Action to perform:
|
||||
- `Browse` - View all prompts
|
||||
- `Search` - Search with filters
|
||||
- `Load Prompt` - Load specific prompt by ID
|
||||
- `Upload Database` - Import and merge database
|
||||
- `Lock Thumbnail` - Lock a thumbnail slot
|
||||
- `Unlock Thumbnail` - Unlock a thumbnail slot
|
||||
|
||||
- `search_text` (STRING) - Text to search
|
||||
- `category_filter` (DROPDOWN) - Filter by category
|
||||
- `min_rating` (INT 0-5) - Minimum rating filter
|
||||
- `prompt_id_to_load` (INT) - ID of prompt to load
|
||||
- `thumbnail_index` (INT 0-2) - Thumbnail slot index
|
||||
- `database_json` (STRING) - Paste JSON for upload
|
||||
- `max_results` (INT) - Maximum results to show
|
||||
|
||||
**Outputs:**
|
||||
- `loaded_text` - Text of loaded prompt
|
||||
- `loaded_category` - Category of loaded prompt
|
||||
- `results_json` - Search results as JSON
|
||||
- `loaded_prompt_id` - ID of loaded prompt
|
||||
|
||||
**Features:**
|
||||
|
||||
**Browsing:**
|
||||
- View all prompts with metadata
|
||||
- See thumbnails, ratings, usage count
|
||||
- Filter by category, tags, rating
|
||||
|
||||
**Searching:**
|
||||
- Full-text search in prompt content and notes
|
||||
- Multiple filter combinations
|
||||
- Sorted by rating and usage
|
||||
|
||||
**Loading:**
|
||||
- Enter prompt ID to load specific prompt
|
||||
- Outputs text and category for use in workflow
|
||||
|
||||
**Database Upload:**
|
||||
- Paste JSON from exported database
|
||||
- Automatically merges with current database
|
||||
- Removes duplicates (by hash)
|
||||
- Keeps higher ratings
|
||||
- Sums usage counts
|
||||
|
||||
---
|
||||
|
||||
### 📤 Export Manager
|
||||
|
||||
Export and manage your database.
|
||||
|
||||
**Inputs:**
|
||||
- `export_action` (DROPDOWN):
|
||||
- `None` - Show basic statistics
|
||||
- `Export Database` - Save database to file
|
||||
- `Show Statistics` - Detailed statistics
|
||||
- `Cleanup Old Prompts` - Remove old unrated prompts
|
||||
|
||||
- `export_filename` (STRING) - Filename for export
|
||||
|
||||
**Outputs:**
|
||||
- `info` - Status information and statistics
|
||||
|
||||
**Features:**
|
||||
|
||||
**Export Database:**
|
||||
- Saves complete database to output folder
|
||||
- Includes all prompts, thumbnails (base64), metadata
|
||||
- Single JSON file - easy to share or backup
|
||||
|
||||
**Statistics:**
|
||||
- Total prompts, categories, tags
|
||||
- Rated vs unrated breakdown
|
||||
- Category distribution
|
||||
- Tag usage
|
||||
- Database size and location
|
||||
|
||||
**Cleanup:**
|
||||
- Removes prompts with rating = 0
|
||||
- Only if older than threshold (default 30 days)
|
||||
- Configurable in database settings
|
||||
- Rated prompts are NEVER deleted
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Thumbnail Management
|
||||
|
||||
### How Thumbnails Work
|
||||
|
||||
Each prompt can store up to **3 thumbnails** with a **lock/unlock** system.
|
||||
|
||||
### Automatic Thumbnail Saving
|
||||
|
||||
When you generate images using a prompt:
|
||||
1. The **last generated image** is automatically saved as a thumbnail
|
||||
2. If all slots are unlocked, the oldest unlocked slot is replaced
|
||||
3. If some slots are locked, only unlocked slots are replaced
|
||||
|
||||
### Lock/Unlock Mechanism
|
||||
|
||||
**Unlocked Slot (default):**
|
||||
- Always gets replaced with the newest generation
|
||||
- Useful for seeing "current best" output
|
||||
|
||||
**Locked Slot:**
|
||||
- Preserved permanently until you unlock it
|
||||
- Useful for keeping reference images
|
||||
|
||||
**Example Workflow:**
|
||||
|
||||
```
|
||||
Generation 1: [Unlocked Slot 1] ← New image
|
||||
Generation 2: [Unlocked Slot 1] ← Replaced
|
||||
→ User clicks lock on Slot 1
|
||||
Generation 3: [Locked Slot 1] [Unlocked Slot 2] ← New image
|
||||
Generation 4: [Locked Slot 1] [Unlocked Slot 2] ← Replaced
|
||||
→ User clicks lock on Slot 2
|
||||
Generation 5: [Locked Slot 1] [Locked Slot 2] [Unlocked Slot 3] ← New
|
||||
```
|
||||
|
||||
### Locking/Unlocking in UI
|
||||
|
||||
Use **Prompt Library Browser** node:
|
||||
1. Set action to "Lock Thumbnail" or "Unlock Thumbnail"
|
||||
2. Enter prompt ID
|
||||
3. Enter thumbnail index (0, 1, or 2)
|
||||
4. Execute
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Structure
|
||||
|
||||
### Location
|
||||
|
||||
```
|
||||
ComfyUI/
|
||||
└── user/
|
||||
└── default/
|
||||
└── workflows/
|
||||
└── prompt_database.json
|
||||
```
|
||||
|
||||
### Format
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"created": "2026-01-14T10:00:00Z",
|
||||
"last_updated": "2026-01-14T12:00:00Z",
|
||||
"settings": {
|
||||
"auto_cleanup_enabled": true,
|
||||
"auto_cleanup_days": 30,
|
||||
"max_thumbnails": 3
|
||||
},
|
||||
"categories": ["Girl 👩🏻", "Clothing 👗", "Lighting 💡"],
|
||||
"tags": ["blonde", "dress", "outdoor"],
|
||||
"prompts": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Beautiful woman with platinum blonde hair...",
|
||||
"category": "Girl 👩🏻",
|
||||
"tags": ["blonde", "blue-eyes"],
|
||||
"rating": 5,
|
||||
"notes": "Best prompt for nordic look",
|
||||
"created_at": "2026-01-14T10:30:00Z",
|
||||
"updated_at": "2026-01-14T11:00:00Z",
|
||||
"used_count": 15,
|
||||
"hash": "a3f5e8d...",
|
||||
"thumbnails": [
|
||||
{
|
||||
"data": "data:image/jpeg;base64,...",
|
||||
"locked": true,
|
||||
"timestamp": "2026-01-14T10:35:00Z",
|
||||
"source_image": "ComfyUI_00123.png"
|
||||
}
|
||||
],
|
||||
"generation_history": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Thumbnails
|
||||
|
||||
- Stored as **base64-encoded JPEG** data URIs
|
||||
- **150x150px** at 70% quality
|
||||
- Approximately **10-12 KB** each
|
||||
- Maximum 3 per prompt = ~35 KB per prompt
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Database Merge Logic
|
||||
|
||||
When merging databases (e.g., from backup or another user):
|
||||
|
||||
1. **Deduplication by Hash**
|
||||
- Prompts with identical text (hash) are considered duplicates
|
||||
- Only one copy is kept
|
||||
|
||||
2. **Conflict Resolution**
|
||||
- **Rating**: Higher rating wins
|
||||
- **Used Count**: Summed (e.g., 20 + 5 = 25)
|
||||
- **Created Date**: Older date kept (original creation time)
|
||||
- **Updated Date**: Newer date kept
|
||||
- **Thumbnails**: Locked thumbnails preserved, newest unlocked added
|
||||
- **Tags**: Merged (union of both sets)
|
||||
|
||||
3. **Categories and Tags**
|
||||
- All unique categories are preserved
|
||||
- All unique tags are preserved
|
||||
|
||||
### Example Merge
|
||||
|
||||
**Database A:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Beautiful woman...",
|
||||
"hash": "abc123",
|
||||
"rating": 5,
|
||||
"used_count": 20
|
||||
}
|
||||
```
|
||||
|
||||
**Database B:**
|
||||
```json
|
||||
{
|
||||
"id": 47,
|
||||
"text": "Beautiful woman...",
|
||||
"hash": "abc123",
|
||||
"rating": 3,
|
||||
"used_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Beautiful woman...",
|
||||
"hash": "abc123",
|
||||
"rating": 5, ← Higher rating
|
||||
"used_count": 25 ← Summed
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Auto-Cleanup
|
||||
|
||||
### How It Works
|
||||
|
||||
- Removes prompts with **rating = 0** (unrated)
|
||||
- Only if older than **30 days** (configurable)
|
||||
- Runs when you use "Cleanup Old Prompts" action
|
||||
- Can be disabled in settings
|
||||
|
||||
### Why?
|
||||
|
||||
- Prevents database bloat from experimental prompts
|
||||
- Keeps only prompts you found valuable (rated)
|
||||
- Encourages rating good prompts to preserve them
|
||||
|
||||
### Protection
|
||||
|
||||
**Prompts are SAFE if:**
|
||||
- Rating > 0 (any rating keeps it forever)
|
||||
- Created within cleanup threshold (e.g., last 30 days)
|
||||
|
||||
**Prompts are REMOVED if:**
|
||||
- Rating = 0 AND
|
||||
- Created more than threshold days ago
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
### 1. Iterative Prompt Development
|
||||
|
||||
```
|
||||
[Smart Text Input] → [Prompt Saver (Unlocked)]
|
||||
↓
|
||||
[Generate Image]
|
||||
↓
|
||||
[View Result]
|
||||
↓
|
||||
[Lock if good, or edit and try again]
|
||||
```
|
||||
|
||||
### 2. Building Prompt Library
|
||||
|
||||
1. Start with Prompt Saver **locked**
|
||||
2. Write different prompts for each category
|
||||
3. Rate them as you test (1-5 stars)
|
||||
4. Best prompts (5 stars) are easy to find later
|
||||
|
||||
### 3. Team Collaboration
|
||||
|
||||
1. Each person exports their database
|
||||
2. Share JSON files
|
||||
3. Import into Prompt Library Browser
|
||||
4. Automatic merge with deduplication
|
||||
5. Everyone has access to best prompts
|
||||
|
||||
### 4. Backup and Restore
|
||||
|
||||
1. Regularly export database (Export Manager)
|
||||
2. Save to cloud storage
|
||||
3. If database corrupted, import from backup
|
||||
4. No data loss
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Advanced Configuration
|
||||
|
||||
### Changing Auto-Cleanup Settings
|
||||
|
||||
Edit `prompt_database.json` directly:
|
||||
|
||||
```json
|
||||
"settings": {
|
||||
"auto_cleanup_enabled": true,
|
||||
"auto_cleanup_days": 30,
|
||||
"max_thumbnails": 3
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `auto_cleanup_enabled`: `true` or `false`
|
||||
- `auto_cleanup_days`: Number of days (e.g., `30`, `60`, `90`)
|
||||
- `max_thumbnails`: 1 to 5 (default 3)
|
||||
|
||||
### Custom Categories
|
||||
|
||||
Categories are created automatically when you type them, or you can add to database:
|
||||
|
||||
```json
|
||||
"categories": [
|
||||
"Girl 👩🏻",
|
||||
"Clothing 👗",
|
||||
"Your Custom Category"
|
||||
]
|
||||
```
|
||||
|
||||
### Custom Tags
|
||||
|
||||
Tags are created automatically when you use them. No configuration needed.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Database Not Saving
|
||||
|
||||
**Problem:** Changes not persisting
|
||||
|
||||
**Solutions:**
|
||||
1. Check file permissions on `ComfyUI/user/default/workflows/`
|
||||
2. Ensure no other process is locking the file
|
||||
3. Check console for error messages
|
||||
4. Try manual export and re-import
|
||||
|
||||
### Thumbnails Not Appearing
|
||||
|
||||
**Problem:** Images not being captured
|
||||
|
||||
**Solutions:**
|
||||
1. Ensure images are saved (PreviewImage or SaveImage node)
|
||||
2. Check that workflow completed successfully
|
||||
3. Verify output directory exists
|
||||
4. May need to implement automatic image detection (coming soon)
|
||||
|
||||
### Lock Button Not Pulsing
|
||||
|
||||
**Problem:** Visual animation not working
|
||||
|
||||
**Solutions:**
|
||||
1. Refresh ComfyUI page (Ctrl+F5)
|
||||
2. Check browser console for JavaScript errors
|
||||
3. Ensure `web/js/prompt_library_ui.js` is loaded
|
||||
|
||||
### Merge Failed
|
||||
|
||||
**Problem:** Upload database gives error
|
||||
|
||||
**Solutions:**
|
||||
1. Validate JSON syntax (use JSONLint.com)
|
||||
2. Ensure it's a valid prompt database (has correct structure)
|
||||
3. Check for file encoding (must be UTF-8)
|
||||
4. Try smaller database first to test
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! Please:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create feature branch
|
||||
3. Test thoroughly
|
||||
4. Submit pull request with clear description
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - feel free to use in your projects!
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
Created by **Daniel Janata** with assistance from **Claude (Anthropic)**
|
||||
|
||||
Special thanks to the ComfyUI community!
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/danieljanata/ComfyUI-Prompting-System/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/danieljanata/ComfyUI-Prompting-System/discussions)
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### Planned Features
|
||||
|
||||
- [ ] Automatic image detection from workflow execution
|
||||
- [ ] Web UI panel for library browser (instead of node)
|
||||
- [ ] Import/export to CSV format
|
||||
- [ ] Prompt templates and variables
|
||||
- [ ] Batch operations (rate multiple, delete multiple)
|
||||
- [ ] Prompt versioning and history
|
||||
- [ ] Shared online library (opt-in)
|
||||
- [ ] AI-powered prompt suggestions
|
||||
- [ ] Integration with popular checkpoint metadata
|
||||
|
||||
---
|
||||
|
||||
## 📊 Statistics
|
||||
|
||||
### Development Info
|
||||
|
||||
- **Lines of Code**: ~2500+
|
||||
- **Development Time**: 2 hours
|
||||
- **Languages**: Python 95%, JavaScript 3%, CSS 2%
|
||||
- **Dependencies**: Minimal (PIL, built-in modules)
|
||||
|
||||
---
|
||||
|
||||
**Happy Prompting! 🎨**
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
ComfyUI Prompting System
|
||||
A comprehensive prompt management system for ComfyUI with database storage and thumbnail support.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add this directory to Python path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# Import nodes
|
||||
from nodes.prompt_saver import PromptSaverNode
|
||||
from nodes.smart_text_input import SmartTextInputNode
|
||||
from nodes.prompt_library_browser import PromptLibraryBrowserNode
|
||||
from nodes.export_manager import ExportManagerNode
|
||||
|
||||
# ComfyUI Node Registration
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PromptSaver": PromptSaverNode,
|
||||
"SmartTextInput": SmartTextInputNode,
|
||||
"PromptLibraryBrowser": PromptLibraryBrowserNode,
|
||||
"ExportManager": ExportManagerNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptSaver": "💾 Prompt Saver",
|
||||
"SmartTextInput": "✏️ Smart Text Input",
|
||||
"PromptLibraryBrowser": "📚 Prompt Library Browser",
|
||||
"ExportManager": "📤 Export Manager",
|
||||
}
|
||||
|
||||
# Web directory for UI components
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
|
||||
# Print loaded info
|
||||
print("=" * 60)
|
||||
print("🎨 ComfyUI Prompting System Loaded!")
|
||||
print("=" * 60)
|
||||
print("Nodes loaded:")
|
||||
for node_name, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
|
||||
print(f" • {display_name}")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Database management for ComfyUI Prompting System"""
|
||||
|
||||
from .db_manager import PromptDatabase
|
||||
from .image_handler import ImageHandler
|
||||
|
||||
__all__ = ['PromptDatabase', 'ImageHandler']
|
||||
@@ -0,0 +1,561 @@
|
||||
"""Main database manager for prompt storage and retrieval"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from .image_handler import ImageHandler
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from utils.path_utils import get_database_path, normalize_path
|
||||
|
||||
|
||||
class PromptDatabase:
|
||||
"""Manages prompt database with JSON storage and base64 thumbnails"""
|
||||
|
||||
VERSION = "1.0"
|
||||
|
||||
def __init__(self, db_path: Optional[Path] = None):
|
||||
"""
|
||||
Initialize PromptDatabase.
|
||||
|
||||
Args:
|
||||
db_path: Optional custom path to database file
|
||||
"""
|
||||
self.db_path = db_path or get_database_path()
|
||||
self.image_handler = ImageHandler()
|
||||
self.data = self._load_or_create()
|
||||
|
||||
def _load_or_create(self) -> Dict[str, Any]:
|
||||
"""Load existing database or create new one"""
|
||||
if self.db_path.exists():
|
||||
try:
|
||||
with open(self.db_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
print(f"[PromptSystem] Loaded database with {len(data.get('prompts', []))} prompts")
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error loading database: {e}")
|
||||
return self._create_empty_database()
|
||||
else:
|
||||
return self._create_empty_database()
|
||||
|
||||
def _create_empty_database(self) -> Dict[str, Any]:
|
||||
"""Create empty database structure"""
|
||||
return {
|
||||
"version": self.VERSION,
|
||||
"created": datetime.now().isoformat(),
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
"settings": {
|
||||
"auto_cleanup_enabled": True,
|
||||
"auto_cleanup_days": 30,
|
||||
"max_thumbnails": 3
|
||||
},
|
||||
"categories": [],
|
||||
"tags": [],
|
||||
"prompts": []
|
||||
}
|
||||
|
||||
def save(self) -> bool:
|
||||
"""Save database to disk"""
|
||||
try:
|
||||
self.data['last_updated'] = datetime.now().isoformat()
|
||||
|
||||
# Ensure directory exists
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to temporary file first, then rename (atomic operation)
|
||||
temp_path = self.db_path.with_suffix('.tmp')
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Rename temp to actual file
|
||||
temp_path.replace(self.db_path)
|
||||
|
||||
print(f"[PromptSystem] Database saved to {self.db_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error saving database: {e}")
|
||||
return False
|
||||
|
||||
def _generate_hash(self, text: str) -> str:
|
||||
"""Generate SHA256 hash of text"""
|
||||
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
||||
|
||||
def _get_next_id(self) -> int:
|
||||
"""Get next available prompt ID"""
|
||||
if not self.data['prompts']:
|
||||
return 1
|
||||
return max(p['id'] for p in self.data['prompts']) + 1
|
||||
|
||||
def add_prompt(
|
||||
self,
|
||||
text: str,
|
||||
category: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: int = 0,
|
||||
notes: str = "",
|
||||
image_path: Optional[str] = None
|
||||
) -> int:
|
||||
"""
|
||||
Add new prompt to database.
|
||||
|
||||
Args:
|
||||
text: Prompt text
|
||||
category: Category name
|
||||
tags: List of tags
|
||||
rating: Rating 0-5
|
||||
notes: Optional notes
|
||||
image_path: Optional path to image for thumbnail
|
||||
|
||||
Returns:
|
||||
Prompt ID
|
||||
"""
|
||||
tags = tags or []
|
||||
prompt_hash = self._generate_hash(text)
|
||||
|
||||
# Check for duplicate
|
||||
existing = self.find_by_hash(prompt_hash)
|
||||
if existing:
|
||||
print(f"[PromptSystem] Prompt already exists with ID {existing['id']}")
|
||||
return existing['id']
|
||||
|
||||
# Create thumbnails if image provided
|
||||
thumbnails = []
|
||||
if image_path:
|
||||
thumb_data = self.image_handler.compress_and_encode(image_path)
|
||||
if thumb_data:
|
||||
thumbnails.append({
|
||||
'data': thumb_data,
|
||||
'locked': False,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'source_image': str(Path(image_path).name)
|
||||
})
|
||||
|
||||
# Create new prompt
|
||||
prompt_id = self._get_next_id()
|
||||
new_prompt = {
|
||||
"id": prompt_id,
|
||||
"text": text,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"rating": rating,
|
||||
"notes": notes,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"used_count": 1,
|
||||
"hash": prompt_hash,
|
||||
"thumbnails": thumbnails,
|
||||
"generation_history": []
|
||||
}
|
||||
|
||||
self.data['prompts'].append(new_prompt)
|
||||
|
||||
# Update categories and tags
|
||||
if category and category not in self.data['categories']:
|
||||
self.data['categories'].append(category)
|
||||
|
||||
for tag in tags:
|
||||
if tag and tag not in self.data['tags']:
|
||||
self.data['tags'].append(tag)
|
||||
|
||||
self.save()
|
||||
print(f"[PromptSystem] Added new prompt with ID {prompt_id}")
|
||||
return prompt_id
|
||||
|
||||
def update_prompt(
|
||||
self,
|
||||
prompt_id: int,
|
||||
text: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Update existing prompt.
|
||||
|
||||
Args:
|
||||
prompt_id: Prompt ID to update
|
||||
text: New text (optional)
|
||||
category: New category (optional)
|
||||
tags: New tags (optional)
|
||||
rating: New rating (optional)
|
||||
notes: New notes (optional)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
prompt = self.get_prompt(prompt_id)
|
||||
if not prompt:
|
||||
print(f"[PromptSystem] Prompt {prompt_id} not found")
|
||||
return False
|
||||
|
||||
# Update fields
|
||||
if text is not None:
|
||||
prompt['text'] = text
|
||||
prompt['hash'] = self._generate_hash(text)
|
||||
|
||||
if category is not None:
|
||||
prompt['category'] = category
|
||||
if category not in self.data['categories']:
|
||||
self.data['categories'].append(category)
|
||||
|
||||
if tags is not None:
|
||||
prompt['tags'] = tags
|
||||
for tag in tags:
|
||||
if tag not in self.data['tags']:
|
||||
self.data['tags'].append(tag)
|
||||
|
||||
if rating is not None:
|
||||
prompt['rating'] = max(0, min(5, rating))
|
||||
|
||||
if notes is not None:
|
||||
prompt['notes'] = notes
|
||||
|
||||
prompt['updated_at'] = datetime.now().isoformat()
|
||||
|
||||
self.save()
|
||||
print(f"[PromptSystem] Updated prompt {prompt_id}")
|
||||
return True
|
||||
|
||||
def get_prompt(self, prompt_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get prompt by ID"""
|
||||
for prompt in self.data['prompts']:
|
||||
if prompt['id'] == prompt_id:
|
||||
return prompt
|
||||
return None
|
||||
|
||||
def find_by_hash(self, prompt_hash: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find prompt by hash"""
|
||||
for prompt in self.data['prompts']:
|
||||
if prompt['hash'] == prompt_hash:
|
||||
return prompt
|
||||
return None
|
||||
|
||||
def delete_prompt(self, prompt_id: int) -> bool:
|
||||
"""Delete prompt by ID"""
|
||||
initial_count = len(self.data['prompts'])
|
||||
self.data['prompts'] = [p for p in self.data['prompts'] if p['id'] != prompt_id]
|
||||
|
||||
if len(self.data['prompts']) < initial_count:
|
||||
self.save()
|
||||
print(f"[PromptSystem] Deleted prompt {prompt_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def add_thumbnail(self, prompt_id: int, image_path: str) -> bool:
|
||||
"""
|
||||
Add thumbnail to prompt with lock/unlock mechanism.
|
||||
|
||||
Args:
|
||||
prompt_id: Prompt ID
|
||||
image_path: Path to image file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
prompt = self.get_prompt(prompt_id)
|
||||
if not prompt:
|
||||
return False
|
||||
|
||||
max_thumbnails = self.data['settings'].get('max_thumbnails', 3)
|
||||
current_thumbnails = prompt.get('thumbnails', [])
|
||||
|
||||
# Use image handler to manage thumbnails
|
||||
updated_thumbnails = self.image_handler.manage_thumbnails(
|
||||
current_thumbnails,
|
||||
image_path,
|
||||
max_thumbnails
|
||||
)
|
||||
|
||||
prompt['thumbnails'] = updated_thumbnails
|
||||
prompt['updated_at'] = datetime.now().isoformat()
|
||||
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def lock_thumbnail(self, prompt_id: int, thumbnail_index: int) -> bool:
|
||||
"""Lock a thumbnail"""
|
||||
prompt = self.get_prompt(prompt_id)
|
||||
if not prompt:
|
||||
return False
|
||||
|
||||
prompt['thumbnails'] = self.image_handler.lock_thumbnail(
|
||||
prompt.get('thumbnails', []),
|
||||
thumbnail_index
|
||||
)
|
||||
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def unlock_thumbnail(self, prompt_id: int, thumbnail_index: int) -> bool:
|
||||
"""Unlock a thumbnail"""
|
||||
prompt = self.get_prompt(prompt_id)
|
||||
if not prompt:
|
||||
return False
|
||||
|
||||
prompt['thumbnails'] = self.image_handler.unlock_thumbnail(
|
||||
prompt.get('thumbnails', []),
|
||||
thumbnail_index
|
||||
)
|
||||
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def add_generation_history(
|
||||
self,
|
||||
prompt_id: int,
|
||||
full_prompt: str,
|
||||
output_image: str,
|
||||
workflow_snapshot: Optional[Dict] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Add generation history entry to prompt.
|
||||
|
||||
Args:
|
||||
prompt_id: Prompt ID
|
||||
full_prompt: Full concatenated prompt used
|
||||
output_image: Path to output image
|
||||
workflow_snapshot: Optional workflow data
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
prompt = self.get_prompt(prompt_id)
|
||||
if not prompt:
|
||||
return False
|
||||
|
||||
history_entry = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'full_prompt': full_prompt,
|
||||
'output_image': normalize_path(output_image),
|
||||
'workflow_snapshot': workflow_snapshot
|
||||
}
|
||||
|
||||
if 'generation_history' not in prompt:
|
||||
prompt['generation_history'] = []
|
||||
|
||||
prompt['generation_history'].append(history_entry)
|
||||
prompt['used_count'] = prompt.get('used_count', 0) + 1
|
||||
prompt['updated_at'] = datetime.now().isoformat()
|
||||
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def search_prompts(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
min_rating: int = 0,
|
||||
max_results: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search prompts with filters.
|
||||
|
||||
Args:
|
||||
search_text: Text to search in prompt content
|
||||
category: Filter by category
|
||||
tags: Filter by tags (any match)
|
||||
min_rating: Minimum rating filter
|
||||
max_results: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching prompts
|
||||
"""
|
||||
results = []
|
||||
|
||||
for prompt in self.data['prompts']:
|
||||
# Rating filter
|
||||
if prompt.get('rating', 0) < min_rating:
|
||||
continue
|
||||
|
||||
# Category filter
|
||||
if category and prompt.get('category') != category:
|
||||
continue
|
||||
|
||||
# Tags filter
|
||||
if tags:
|
||||
prompt_tags = set(prompt.get('tags', []))
|
||||
if not any(tag in prompt_tags for tag in tags):
|
||||
continue
|
||||
|
||||
# Text search
|
||||
if search_text:
|
||||
search_lower = search_text.lower()
|
||||
if search_lower not in prompt.get('text', '').lower():
|
||||
if search_lower not in prompt.get('notes', '').lower():
|
||||
continue
|
||||
|
||||
results.append(prompt)
|
||||
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
# Sort by used_count and rating
|
||||
results.sort(key=lambda p: (p.get('rating', 0), p.get('used_count', 0)), reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
def get_latest_prompt_by_category(self, category: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get most recent prompt in category"""
|
||||
category_prompts = [p for p in self.data['prompts'] if p.get('category') == category]
|
||||
|
||||
if not category_prompts:
|
||||
return None
|
||||
|
||||
# Sort by updated_at
|
||||
category_prompts.sort(key=lambda p: p.get('updated_at', ''), reverse=True)
|
||||
return category_prompts[0]
|
||||
|
||||
def cleanup_old_unrated(self) -> int:
|
||||
"""
|
||||
Remove old unrated prompts based on settings.
|
||||
|
||||
Returns:
|
||||
Number of prompts removed
|
||||
"""
|
||||
if not self.data['settings'].get('auto_cleanup_enabled', True):
|
||||
return 0
|
||||
|
||||
cleanup_days = self.data['settings'].get('auto_cleanup_days', 30)
|
||||
cutoff_date = datetime.now() - timedelta(days=cleanup_days)
|
||||
cutoff_iso = cutoff_date.isoformat()
|
||||
|
||||
initial_count = len(self.data['prompts'])
|
||||
|
||||
# Remove prompts that are:
|
||||
# - Unrated (rating = 0)
|
||||
# - Older than cutoff date
|
||||
self.data['prompts'] = [
|
||||
p for p in self.data['prompts']
|
||||
if p.get('rating', 0) > 0 or p.get('created_at', '') > cutoff_iso
|
||||
]
|
||||
|
||||
removed = initial_count - len(self.data['prompts'])
|
||||
|
||||
if removed > 0:
|
||||
self.save()
|
||||
print(f"[PromptSystem] Cleaned up {removed} old unrated prompts")
|
||||
|
||||
return removed
|
||||
|
||||
def merge_database(self, other_db_path: Path) -> Dict[str, int]:
|
||||
"""
|
||||
Merge another database into this one.
|
||||
|
||||
Logic:
|
||||
- Deduplicate by hash
|
||||
- Higher rating wins
|
||||
- Sum used_count
|
||||
- Keep older created_at
|
||||
- Merge thumbnails (keep locked + newest)
|
||||
|
||||
Args:
|
||||
other_db_path: Path to other database JSON file
|
||||
|
||||
Returns:
|
||||
Dictionary with merge statistics
|
||||
"""
|
||||
try:
|
||||
with open(other_db_path, 'r', encoding='utf-8') as f:
|
||||
other_data = json.load(f)
|
||||
|
||||
stats = {
|
||||
'added': 0,
|
||||
'merged': 0,
|
||||
'skipped': 0
|
||||
}
|
||||
|
||||
for other_prompt in other_data.get('prompts', []):
|
||||
other_hash = other_prompt.get('hash')
|
||||
|
||||
# Find existing by hash
|
||||
existing = self.find_by_hash(other_hash)
|
||||
|
||||
if not existing:
|
||||
# New prompt - add it
|
||||
# Reassign ID to avoid conflicts
|
||||
new_id = self._get_next_id()
|
||||
other_prompt['id'] = new_id
|
||||
self.data['prompts'].append(other_prompt)
|
||||
stats['added'] += 1
|
||||
else:
|
||||
# Merge with existing
|
||||
# Higher rating wins
|
||||
if other_prompt.get('rating', 0) > existing.get('rating', 0):
|
||||
existing['rating'] = other_prompt['rating']
|
||||
|
||||
# Sum used_count
|
||||
existing['used_count'] = (
|
||||
existing.get('used_count', 0) +
|
||||
other_prompt.get('used_count', 0)
|
||||
)
|
||||
|
||||
# Keep older created_at
|
||||
if other_prompt.get('created_at', '') < existing.get('created_at', ''):
|
||||
existing['created_at'] = other_prompt['created_at']
|
||||
|
||||
# Merge thumbnails
|
||||
existing['thumbnails'] = self.image_handler.merge_thumbnails(
|
||||
existing.get('thumbnails', []),
|
||||
other_prompt.get('thumbnails', []),
|
||||
self.data['settings'].get('max_thumbnails', 3)
|
||||
)
|
||||
|
||||
# Merge generation history
|
||||
existing_history = existing.get('generation_history', [])
|
||||
other_history = other_prompt.get('generation_history', [])
|
||||
existing['generation_history'] = existing_history + other_history
|
||||
|
||||
# Update timestamp
|
||||
existing['updated_at'] = datetime.now().isoformat()
|
||||
|
||||
stats['merged'] += 1
|
||||
|
||||
# Merge categories and tags
|
||||
for cat in other_data.get('categories', []):
|
||||
if cat not in self.data['categories']:
|
||||
self.data['categories'].append(cat)
|
||||
|
||||
for tag in other_data.get('tags', []):
|
||||
if tag not in self.data['tags']:
|
||||
self.data['tags'].append(tag)
|
||||
|
||||
self.save()
|
||||
print(f"[PromptSystem] Merge complete: {stats}")
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error merging database: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
def export_to_file(self, export_path: Path) -> bool:
|
||||
"""Export database to file"""
|
||||
try:
|
||||
with open(export_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.data, f, indent=2, ensure_ascii=False)
|
||||
print(f"[PromptSystem] Exported database to {export_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error exporting database: {e}")
|
||||
return False
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""Get database statistics"""
|
||||
return {
|
||||
'total_prompts': len(self.data['prompts']),
|
||||
'total_categories': len(self.data['categories']),
|
||||
'total_tags': len(self.data['tags']),
|
||||
'rated_prompts': len([p for p in self.data['prompts'] if p.get('rating', 0) > 0]),
|
||||
'unrated_prompts': len([p for p in self.data['prompts'] if p.get('rating', 0) == 0]),
|
||||
'prompts_with_thumbnails': len([p for p in self.data['prompts'] if p.get('thumbnails')]),
|
||||
'total_generations': sum(p.get('used_count', 0) for p in self.data['prompts'])
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Handle image compression, base64 encoding, and thumbnail management"""
|
||||
|
||||
import io
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
from PIL import Image
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ImageHandler:
|
||||
"""Manages image thumbnails with lock/unlock mechanism"""
|
||||
|
||||
def __init__(self, thumbnail_size: tuple = (150, 150), quality: int = 70):
|
||||
"""
|
||||
Initialize ImageHandler.
|
||||
|
||||
Args:
|
||||
thumbnail_size: Tuple of (width, height) for thumbnails
|
||||
quality: JPEG quality (0-100)
|
||||
"""
|
||||
self.thumbnail_size = thumbnail_size
|
||||
self.quality = quality
|
||||
|
||||
def compress_and_encode(self, image_path: str) -> Optional[str]:
|
||||
"""
|
||||
Compress image and encode to base64 data URI.
|
||||
|
||||
Args:
|
||||
image_path: Path to source image
|
||||
|
||||
Returns:
|
||||
Base64 data URI string or None on error
|
||||
"""
|
||||
try:
|
||||
# Open and convert to RGB (handles RGBA, grayscale, etc.)
|
||||
img = Image.open(image_path)
|
||||
|
||||
# Convert RGBA to RGB if needed
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Create thumbnail (maintains aspect ratio)
|
||||
img.thumbnail(self.thumbnail_size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Encode to JPEG in memory
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='JPEG', quality=self.quality, optimize=True)
|
||||
buffer.seek(0)
|
||||
|
||||
# Encode to base64
|
||||
img_base64 = base64.b64encode(buffer.read()).decode('utf-8')
|
||||
|
||||
# Return as data URI
|
||||
return f"data:image/jpeg;base64,{img_base64}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error compressing image {image_path}: {e}")
|
||||
return None
|
||||
|
||||
def decode_and_save(self, base64_data: str, output_path: str) -> bool:
|
||||
"""
|
||||
Decode base64 data URI and save to file.
|
||||
|
||||
Args:
|
||||
base64_data: Base64 data URI string
|
||||
output_path: Path to save the image
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Remove data URI prefix if present
|
||||
if base64_data.startswith('data:image'):
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
|
||||
# Decode base64
|
||||
img_data = base64.b64decode(base64_data)
|
||||
|
||||
# Save to file
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(img_data)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error decoding and saving image: {e}")
|
||||
return False
|
||||
|
||||
def manage_thumbnails(
|
||||
self,
|
||||
current_thumbnails: List[Dict],
|
||||
new_image_path: str,
|
||||
max_thumbnails: int = 3
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Manage thumbnail slots with lock/unlock mechanism.
|
||||
|
||||
Logic:
|
||||
- Unlocked slots are always replaced with the newest image
|
||||
- Locked slots are preserved
|
||||
- Maximum of max_thumbnails slots
|
||||
- When all locked and adding new, remove oldest locked
|
||||
|
||||
Args:
|
||||
current_thumbnails: List of thumbnail dicts with 'data', 'locked', 'timestamp'
|
||||
new_image_path: Path to new image to add
|
||||
max_thumbnails: Maximum number of thumbnails to keep
|
||||
|
||||
Returns:
|
||||
Updated list of thumbnails
|
||||
"""
|
||||
# Encode new image
|
||||
new_thumbnail_data = self.compress_and_encode(new_image_path)
|
||||
if not new_thumbnail_data:
|
||||
return current_thumbnails
|
||||
|
||||
new_thumbnail = {
|
||||
'data': new_thumbnail_data,
|
||||
'locked': False,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'source_image': str(Path(new_image_path).name)
|
||||
}
|
||||
|
||||
# If no thumbnails yet, just add it
|
||||
if not current_thumbnails:
|
||||
return [new_thumbnail]
|
||||
|
||||
# Find unlocked slot
|
||||
unlocked_index = None
|
||||
for i, thumb in enumerate(current_thumbnails):
|
||||
if not thumb.get('locked', False):
|
||||
unlocked_index = i
|
||||
break
|
||||
|
||||
# Replace unlocked slot
|
||||
if unlocked_index is not None:
|
||||
current_thumbnails[unlocked_index] = new_thumbnail
|
||||
return current_thumbnails
|
||||
|
||||
# All locked - add new slot if under max
|
||||
if len(current_thumbnails) < max_thumbnails:
|
||||
current_thumbnails.append(new_thumbnail)
|
||||
return current_thumbnails
|
||||
|
||||
# All locked and at max - remove oldest locked
|
||||
# Sort by timestamp, keep newest (max_thumbnails - 1), then add new
|
||||
current_thumbnails.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
||||
return current_thumbnails[:max_thumbnails - 1] + [new_thumbnail]
|
||||
|
||||
def lock_thumbnail(self, thumbnails: List[Dict], index: int) -> List[Dict]:
|
||||
"""
|
||||
Lock a specific thumbnail by index.
|
||||
|
||||
Args:
|
||||
thumbnails: List of thumbnail dicts
|
||||
index: Index of thumbnail to lock
|
||||
|
||||
Returns:
|
||||
Updated thumbnails list
|
||||
"""
|
||||
if 0 <= index < len(thumbnails):
|
||||
thumbnails[index]['locked'] = True
|
||||
return thumbnails
|
||||
|
||||
def unlock_thumbnail(self, thumbnails: List[Dict], index: int) -> List[Dict]:
|
||||
"""
|
||||
Unlock a specific thumbnail by index.
|
||||
|
||||
Args:
|
||||
thumbnails: List of thumbnail dicts
|
||||
index: Index of thumbnail to unlock
|
||||
|
||||
Returns:
|
||||
Updated thumbnails list
|
||||
"""
|
||||
if 0 <= index < len(thumbnails):
|
||||
thumbnails[index]['locked'] = False
|
||||
return thumbnails
|
||||
|
||||
def merge_thumbnails(
|
||||
self,
|
||||
thumbnails_a: List[Dict],
|
||||
thumbnails_b: List[Dict],
|
||||
max_thumbnails: int = 3
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Merge two thumbnail lists, keeping locked ones and newest.
|
||||
|
||||
Args:
|
||||
thumbnails_a: First thumbnail list
|
||||
thumbnails_b: Second thumbnail list
|
||||
max_thumbnails: Maximum thumbnails to keep
|
||||
|
||||
Returns:
|
||||
Merged thumbnail list
|
||||
"""
|
||||
# Collect all locked thumbnails
|
||||
locked = [t for t in thumbnails_a if t.get('locked', False)]
|
||||
locked.extend([t for t in thumbnails_b if t.get('locked', False)])
|
||||
|
||||
# Remove duplicates based on data (same image)
|
||||
seen_data = set()
|
||||
unique_locked = []
|
||||
for thumb in locked:
|
||||
data_hash = hash(thumb.get('data', ''))
|
||||
if data_hash not in seen_data:
|
||||
seen_data.add(data_hash)
|
||||
unique_locked.append(thumb)
|
||||
|
||||
# If more than max, keep newest locked
|
||||
if len(unique_locked) > max_thumbnails:
|
||||
unique_locked.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
||||
return unique_locked[:max_thumbnails]
|
||||
|
||||
# If under max, add newest unlocked to fill
|
||||
if len(unique_locked) < max_thumbnails:
|
||||
unlocked = [t for t in thumbnails_a if not t.get('locked', False)]
|
||||
unlocked.extend([t for t in thumbnails_b if not t.get('locked', False)])
|
||||
|
||||
# Sort by timestamp, newest first
|
||||
unlocked.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
||||
|
||||
# Add unlocked until we reach max
|
||||
for thumb in unlocked:
|
||||
if len(unique_locked) >= max_thumbnails:
|
||||
break
|
||||
data_hash = hash(thumb.get('data', ''))
|
||||
if data_hash not in seen_data:
|
||||
seen_data.add(data_hash)
|
||||
unique_locked.append(thumb)
|
||||
|
||||
return unique_locked
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Custom nodes for ComfyUI Prompting System"""
|
||||
|
||||
from .prompt_saver import PromptSaverNode
|
||||
from .smart_text_input import SmartTextInputNode
|
||||
from .prompt_library_browser import PromptLibraryBrowserNode
|
||||
from .export_manager import ExportManagerNode
|
||||
|
||||
__all__ = [
|
||||
'PromptSaverNode',
|
||||
'SmartTextInputNode',
|
||||
'PromptLibraryBrowserNode',
|
||||
'ExportManagerNode'
|
||||
]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Export Manager Node - Export database and workflow"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.db_manager import PromptDatabase
|
||||
from utils.path_utils import get_database_path, get_comfy_dir
|
||||
|
||||
|
||||
class ExportManagerNode:
|
||||
"""
|
||||
Node for exporting prompt database and workflow.
|
||||
Provides download buttons for database and workflow files.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.db = PromptDatabase()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types"""
|
||||
return {
|
||||
"required": {
|
||||
"export_action": ([
|
||||
"None",
|
||||
"Export Database",
|
||||
"Show Statistics",
|
||||
"Cleanup Old Prompts"
|
||||
], {
|
||||
"default": "None"
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"export_filename": ("STRING", {
|
||||
"default": f"prompt_database_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
|
||||
"multiline": False
|
||||
}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
"prompt": "PROMPT"
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("info",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "🎨 Prompting System"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def process(
|
||||
self,
|
||||
export_action: str,
|
||||
export_filename: str = "",
|
||||
unique_id=None,
|
||||
extra_pnginfo=None,
|
||||
prompt=None
|
||||
):
|
||||
"""Process export action"""
|
||||
|
||||
if export_action == "None":
|
||||
stats = self.db.get_statistics()
|
||||
info = f"📊 Database Statistics:\n"
|
||||
info += f"Total Prompts: {stats['total_prompts']}\n"
|
||||
info += f"Categories: {stats['total_categories']}\n"
|
||||
info += f"Tags: {stats['total_tags']}\n"
|
||||
info += f"Rated: {stats['rated_prompts']}\n"
|
||||
info += f"Unrated: {stats['unrated_prompts']}\n"
|
||||
info += f"With Thumbnails: {stats['prompts_with_thumbnails']}\n"
|
||||
info += f"Total Generations: {stats['total_generations']}"
|
||||
return (info,)
|
||||
|
||||
elif export_action == "Export Database":
|
||||
# Export to output directory for download
|
||||
comfy_dir = get_comfy_dir()
|
||||
output_dir = comfy_dir / "output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
if not export_filename:
|
||||
export_filename = f"prompt_database_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
|
||||
export_path = output_dir / export_filename
|
||||
|
||||
success = self.db.export_to_file(export_path)
|
||||
|
||||
if success:
|
||||
info = f"✅ Database exported to:\n{export_path}\n\n"
|
||||
info += f"File size: {export_path.stat().st_size / 1024:.2f} KB\n"
|
||||
info += "Check your ComfyUI output folder to download."
|
||||
else:
|
||||
info = "❌ Export failed. Check console for errors."
|
||||
|
||||
return (info,)
|
||||
|
||||
elif export_action == "Show Statistics":
|
||||
stats = self.db.get_statistics()
|
||||
|
||||
info = "📊 Detailed Database Statistics\n"
|
||||
info += "=" * 40 + "\n\n"
|
||||
info += f"📝 Total Prompts: {stats['total_prompts']}\n"
|
||||
info += f" ⭐ Rated: {stats['rated_prompts']}\n"
|
||||
info += f" ☆ Unrated: {stats['unrated_prompts']}\n\n"
|
||||
|
||||
info += f"📁 Categories: {stats['total_categories']}\n"
|
||||
categories = self.db.data.get('categories', [])
|
||||
for cat in categories[:10]: # Show first 10
|
||||
cat_prompts = [p for p in self.db.data['prompts'] if p.get('category') == cat]
|
||||
info += f" • {cat}: {len(cat_prompts)} prompts\n"
|
||||
|
||||
if len(categories) > 10:
|
||||
info += f" ... and {len(categories) - 10} more\n"
|
||||
|
||||
info += f"\n🏷️ Tags: {stats['total_tags']}\n"
|
||||
tags = self.db.data.get('tags', [])
|
||||
info += f" {', '.join(tags[:20])}\n" # Show first 20 tags
|
||||
|
||||
if len(tags) > 20:
|
||||
info += f" ... and {len(tags) - 20} more\n"
|
||||
|
||||
info += f"\n🖼️ Prompts with thumbnails: {stats['prompts_with_thumbnails']}\n"
|
||||
info += f"🎨 Total generations: {stats['total_generations']}\n\n"
|
||||
|
||||
info += f"💾 Database location:\n{get_database_path()}\n"
|
||||
|
||||
return (info,)
|
||||
|
||||
elif export_action == "Cleanup Old Prompts":
|
||||
removed = self.db.cleanup_old_unrated()
|
||||
|
||||
if removed > 0:
|
||||
info = f"🧹 Cleanup Complete!\n"
|
||||
info += f"Removed {removed} old unrated prompts.\n\n"
|
||||
stats = self.db.get_statistics()
|
||||
info += f"Remaining prompts: {stats['total_prompts']}"
|
||||
else:
|
||||
info = "✨ No cleanup needed. All prompts are either:\n"
|
||||
info += "• Rated (kept permanently)\n"
|
||||
info += "• Recent (within cleanup threshold)\n\n"
|
||||
info += f"Cleanup threshold: {self.db.data['settings']['auto_cleanup_days']} days"
|
||||
|
||||
return (info,)
|
||||
|
||||
return ("Unknown action",)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Re-execute when action changes"""
|
||||
return kwargs.get('export_action', 'None')
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Prompt Library Browser Node - Browse, search, and manage prompts with UI"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.db_manager import PromptDatabase
|
||||
from utils.path_utils import get_database_path
|
||||
|
||||
|
||||
class PromptLibraryBrowserNode:
|
||||
"""
|
||||
Interactive prompt library browser with UI.
|
||||
Allows searching, filtering, loading, and managing prompts.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.db = PromptDatabase()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types"""
|
||||
db = PromptDatabase()
|
||||
categories = ["All"] + db.data.get('categories', [])
|
||||
tags = db.data.get('tags', [])
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"action": ([
|
||||
"Browse",
|
||||
"Search",
|
||||
"Load Prompt",
|
||||
"Upload Database",
|
||||
"Lock Thumbnail",
|
||||
"Unlock Thumbnail"
|
||||
], {
|
||||
"default": "Browse"
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"search_text": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "Search prompts..."
|
||||
}),
|
||||
"category_filter": (categories, {
|
||||
"default": "All"
|
||||
}),
|
||||
"min_rating": ("INT", {
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 5,
|
||||
"step": 1,
|
||||
"display": "slider"
|
||||
}),
|
||||
"prompt_id_to_load": ("INT", {
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 999999,
|
||||
"step": 1
|
||||
}),
|
||||
"thumbnail_index": ("INT", {
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 2,
|
||||
"step": 1,
|
||||
"tooltip": "Thumbnail slot (0, 1, or 2)"
|
||||
}),
|
||||
"database_json": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Paste database JSON here to upload..."
|
||||
}),
|
||||
"max_results": ("INT", {
|
||||
"default": 20,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"step": 1
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("loaded_text", "loaded_category", "results_json", "loaded_prompt_id")
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "🎨 Prompting System"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def process(
|
||||
self,
|
||||
action: str,
|
||||
search_text: str = "",
|
||||
category_filter: str = "All",
|
||||
min_rating: int = 0,
|
||||
prompt_id_to_load: int = 0,
|
||||
thumbnail_index: int = 0,
|
||||
database_json: str = "",
|
||||
max_results: int = 20
|
||||
):
|
||||
"""Process library browser action"""
|
||||
|
||||
if action == "Browse" or action == "Search":
|
||||
# Search prompts
|
||||
search_filter = search_text if search_text else None
|
||||
cat_filter = category_filter if category_filter != "All" else None
|
||||
|
||||
results = self.db.search_prompts(
|
||||
search_text=search_filter,
|
||||
category=cat_filter,
|
||||
min_rating=min_rating,
|
||||
max_results=max_results
|
||||
)
|
||||
|
||||
# Convert to JSON for output
|
||||
results_json = json.dumps(results, indent=2, ensure_ascii=False)
|
||||
|
||||
# Create readable summary
|
||||
summary = f"📚 Found {len(results)} prompts\n"
|
||||
summary += "=" * 50 + "\n\n"
|
||||
|
||||
for prompt in results[:10]: # Show first 10 in summary
|
||||
summary += f"ID: {prompt['id']} | ⭐ {prompt.get('rating', 0)}/5 | 🏷️ {prompt.get('category', 'N/A')}\n"
|
||||
summary += f"Text: {prompt['text'][:80]}...\n"
|
||||
|
||||
if prompt.get('tags'):
|
||||
summary += f"Tags: {', '.join(prompt['tags'])}\n"
|
||||
|
||||
thumb_count = len(prompt.get('thumbnails', []))
|
||||
if thumb_count > 0:
|
||||
locked_count = sum(1 for t in prompt['thumbnails'] if t.get('locked', False))
|
||||
summary += f"🖼️ {thumb_count} thumbnails ({locked_count} locked)\n"
|
||||
|
||||
summary += f"Used: {prompt.get('used_count', 0)}x\n"
|
||||
summary += "-" * 50 + "\n"
|
||||
|
||||
if len(results) > 10:
|
||||
summary += f"\n... and {len(results) - 10} more results.\n"
|
||||
summary += "Use 'Load Prompt' action with prompt ID to load specific prompt."
|
||||
|
||||
return ("", "", summary, 0)
|
||||
|
||||
elif action == "Load Prompt":
|
||||
# Load specific prompt by ID
|
||||
if prompt_id_to_load == 0:
|
||||
return ("", "", "❌ Please specify a prompt ID to load.", 0)
|
||||
|
||||
prompt = self.db.get_prompt(prompt_id_to_load)
|
||||
|
||||
if not prompt:
|
||||
return ("", "", f"❌ Prompt ID {prompt_id_to_load} not found.", 0)
|
||||
|
||||
loaded_text = prompt['text']
|
||||
loaded_category = prompt.get('category', 'Uncategorized')
|
||||
|
||||
info = f"✅ Loaded Prompt ID: {prompt_id_to_load}\n"
|
||||
info += f"Category: {loaded_category}\n"
|
||||
info += f"Rating: {'⭐' * prompt.get('rating', 0)}\n"
|
||||
info += f"Tags: {', '.join(prompt.get('tags', []))}\n"
|
||||
info += f"Used: {prompt.get('used_count', 0)}x\n"
|
||||
info += f"Created: {prompt.get('created_at', 'N/A')}\n\n"
|
||||
|
||||
if prompt.get('notes'):
|
||||
info += f"Notes: {prompt['notes']}\n\n"
|
||||
|
||||
if prompt.get('thumbnails'):
|
||||
info += f"🖼️ {len(prompt['thumbnails'])} thumbnails available\n"
|
||||
|
||||
# Return detailed JSON
|
||||
prompt_json = json.dumps(prompt, indent=2, ensure_ascii=False)
|
||||
|
||||
return (loaded_text, loaded_category, info, prompt_id_to_load)
|
||||
|
||||
elif action == "Upload Database":
|
||||
# Upload and merge database
|
||||
if not database_json or database_json.strip() == "":
|
||||
return ("", "", "❌ Please paste database JSON to upload.", 0)
|
||||
|
||||
try:
|
||||
# Parse JSON
|
||||
uploaded_data = json.loads(database_json)
|
||||
|
||||
# Save to temporary file
|
||||
temp_path = get_database_path().parent / "temp_upload.json"
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(uploaded_data, f)
|
||||
|
||||
# Merge with current database
|
||||
stats = self.db.merge_database(temp_path)
|
||||
|
||||
# Clean up temp file
|
||||
temp_path.unlink()
|
||||
|
||||
info = "✅ Database merged successfully!\n\n"
|
||||
info += f"📥 Added: {stats.get('added', 0)} new prompts\n"
|
||||
info += f"🔄 Merged: {stats.get('merged', 0)} existing prompts\n\n"
|
||||
info += "Changes:\n"
|
||||
info += "• Duplicates removed (by hash)\n"
|
||||
info += "• Ratings: higher value kept\n"
|
||||
info += "• Used counts: summed\n"
|
||||
info += "• Thumbnails: locked ones + newest unlocked kept\n"
|
||||
|
||||
return ("", "", info, 0)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
return ("", "", f"❌ Invalid JSON: {str(e)}", 0)
|
||||
except Exception as e:
|
||||
return ("", "", f"❌ Upload failed: {str(e)}", 0)
|
||||
|
||||
elif action == "Lock Thumbnail":
|
||||
if prompt_id_to_load == 0:
|
||||
return ("", "", "❌ Please specify a prompt ID.", 0)
|
||||
|
||||
success = self.db.lock_thumbnail(prompt_id_to_load, thumbnail_index)
|
||||
|
||||
if success:
|
||||
info = f"🔒 Locked thumbnail {thumbnail_index} for prompt {prompt_id_to_load}"
|
||||
else:
|
||||
info = f"❌ Failed to lock thumbnail. Check prompt ID and index."
|
||||
|
||||
return ("", "", info, 0)
|
||||
|
||||
elif action == "Unlock Thumbnail":
|
||||
if prompt_id_to_load == 0:
|
||||
return ("", "", "❌ Please specify a prompt ID.", 0)
|
||||
|
||||
success = self.db.unlock_thumbnail(prompt_id_to_load, thumbnail_index)
|
||||
|
||||
if success:
|
||||
info = f"🔓 Unlocked thumbnail {thumbnail_index} for prompt {prompt_id_to_load}"
|
||||
else:
|
||||
info = f"❌ Failed to unlock thumbnail. Check prompt ID and index."
|
||||
|
||||
return ("", "", info, 0)
|
||||
|
||||
return ("", "", "Unknown action", 0)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Re-execute when inputs change"""
|
||||
return kwargs.get('action', '') + str(kwargs.get('prompt_id_to_load', 0))
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Prompt Saver Node - Saves prompts with metadata and lock/unlock mechanism"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database.db_manager import PromptDatabase
|
||||
|
||||
|
||||
class PromptSaverNode:
|
||||
"""
|
||||
Node for saving prompts with category, tags, rating, and notes.
|
||||
Features lock/unlock mechanism to control when to overwrite from database.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.db = PromptDatabase()
|
||||
self.last_text = {} # Store last text per node instance
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node"""
|
||||
db = PromptDatabase()
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"dynamicPrompts": False
|
||||
}),
|
||||
"locked": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"label_on": "🔒 LOCKED",
|
||||
"label_off": "🔓 UNLOCKED (Click to lock)"
|
||||
}),
|
||||
"category": (db.data.get('categories', []) + ["+ New Category"], {
|
||||
"default": "+ New Category"
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"new_category": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False
|
||||
}),
|
||||
"tags": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "tag1, tag2, tag3"
|
||||
}),
|
||||
"rating": ("INT", {
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 5,
|
||||
"step": 1,
|
||||
"display": "slider"
|
||||
}),
|
||||
"notes": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True
|
||||
}),
|
||||
"auto_save": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"label_on": "Auto-save enabled",
|
||||
"label_off": "Auto-save disabled"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("text", "category", "prompt_id")
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "🎨 Prompting System"
|
||||
|
||||
def _calculate_similarity(self, text1: str, text2: str) -> float:
|
||||
"""Calculate similarity ratio between two texts (0.0 to 1.0)"""
|
||||
return SequenceMatcher(None, text1, text2).ratio()
|
||||
|
||||
def _is_complete_rewrite(self, old_text: str, new_text: str) -> bool:
|
||||
"""
|
||||
Detect if text is completely rewritten.
|
||||
Returns True if similarity < 20%
|
||||
"""
|
||||
if not old_text or not new_text:
|
||||
return True
|
||||
|
||||
similarity = self._calculate_similarity(old_text, new_text)
|
||||
return similarity < 0.2
|
||||
|
||||
def process(
|
||||
self,
|
||||
text: str,
|
||||
locked: bool,
|
||||
category: str,
|
||||
new_category: str = "",
|
||||
tags: str = "",
|
||||
rating: int = 0,
|
||||
notes: str = "",
|
||||
auto_save: bool = True
|
||||
):
|
||||
"""
|
||||
Main processing function.
|
||||
|
||||
Logic:
|
||||
- If unlocked: Load latest prompt from category and overwrite text
|
||||
- If locked: Save current text (new or update based on similarity)
|
||||
"""
|
||||
# Resolve category
|
||||
if category == "+ New Category" and new_category:
|
||||
category = new_category
|
||||
elif category == "+ New Category":
|
||||
category = "Uncategorized"
|
||||
|
||||
# Parse tags
|
||||
tag_list = [t.strip() for t in tags.split(',') if t.strip()]
|
||||
|
||||
# Get node instance ID for tracking
|
||||
node_id = id(self)
|
||||
|
||||
if not locked:
|
||||
# UNLOCKED MODE: Load latest prompt from database
|
||||
latest_prompt = self.db.get_latest_prompt_by_category(category)
|
||||
|
||||
if latest_prompt:
|
||||
text = latest_prompt.get('text', '')
|
||||
tags = ', '.join(latest_prompt.get('tags', []))
|
||||
rating = latest_prompt.get('rating', 0)
|
||||
notes = latest_prompt.get('notes', '')
|
||||
prompt_id = latest_prompt.get('id', 0)
|
||||
|
||||
print(f"[PromptSaver] Loaded prompt ID {prompt_id} from category '{category}'")
|
||||
|
||||
# Store as last text for this node
|
||||
self.last_text[node_id] = text
|
||||
|
||||
return (text, category, prompt_id)
|
||||
else:
|
||||
print(f"[PromptSaver] No prompts found in category '{category}'")
|
||||
return (text, category, 0)
|
||||
|
||||
else:
|
||||
# LOCKED MODE: Save current text
|
||||
if not auto_save:
|
||||
# Auto-save disabled, just return current values
|
||||
return (text, category, 0)
|
||||
|
||||
last_text = self.last_text.get(node_id, "")
|
||||
|
||||
# Detect if this is a new prompt or update
|
||||
if not last_text or self._is_complete_rewrite(last_text, text):
|
||||
# NEW PROMPT
|
||||
prompt_id = self.db.add_prompt(
|
||||
text=text,
|
||||
category=category,
|
||||
tags=tag_list,
|
||||
rating=rating,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
print(f"[PromptSaver] Created new prompt ID {prompt_id}")
|
||||
|
||||
# Update last text
|
||||
self.last_text[node_id] = text
|
||||
|
||||
return (text, category, prompt_id)
|
||||
else:
|
||||
# UPDATE EXISTING - find by similarity
|
||||
# For now, update the latest prompt in category
|
||||
latest_prompt = self.db.get_latest_prompt_by_category(category)
|
||||
|
||||
if latest_prompt:
|
||||
prompt_id = latest_prompt['id']
|
||||
|
||||
self.db.update_prompt(
|
||||
prompt_id=prompt_id,
|
||||
text=text,
|
||||
category=category,
|
||||
tags=tag_list,
|
||||
rating=rating,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
print(f"[PromptSaver] Updated prompt ID {prompt_id}")
|
||||
|
||||
# Update last text
|
||||
self.last_text[node_id] = text
|
||||
|
||||
return (text, category, prompt_id)
|
||||
else:
|
||||
# No existing prompt, create new
|
||||
prompt_id = self.db.add_prompt(
|
||||
text=text,
|
||||
category=category,
|
||||
tags=tag_list,
|
||||
rating=rating,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
print(f"[PromptSaver] Created new prompt ID {prompt_id}")
|
||||
self.last_text[node_id] = text
|
||||
|
||||
return (text, category, prompt_id)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution on every run"""
|
||||
import random
|
||||
return random.random()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Smart Text Input Node - Text input with change detection"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
class SmartTextInputNode:
|
||||
"""
|
||||
Enhanced text input node with change detection.
|
||||
Outputs text and a flag indicating if it's a complete rewrite or minor edit.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.previous_text = {} # Store previous text per node instance
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types"""
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"dynamicPrompts": True
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"similarity_threshold": ("FLOAT", {
|
||||
"default": 0.2,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.05,
|
||||
"display": "slider",
|
||||
"tooltip": "Below this threshold = complete rewrite"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "BOOLEAN", "FLOAT")
|
||||
RETURN_NAMES = ("text", "is_new_prompt", "similarity")
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "🎨 Prompting System"
|
||||
|
||||
def _calculate_similarity(self, text1: str, text2: str) -> float:
|
||||
"""Calculate similarity ratio between two texts (0.0 to 1.0)"""
|
||||
if not text1 or not text2:
|
||||
return 0.0
|
||||
return SequenceMatcher(None, text1, text2).ratio()
|
||||
|
||||
def process(self, text: str, similarity_threshold: float = 0.2):
|
||||
"""
|
||||
Process text input and detect changes.
|
||||
|
||||
Returns:
|
||||
- text: The input text
|
||||
- is_new_prompt: True if complete rewrite detected
|
||||
- similarity: Similarity score with previous text
|
||||
"""
|
||||
node_id = id(self)
|
||||
previous_text = self.previous_text.get(node_id, "")
|
||||
|
||||
# Calculate similarity
|
||||
if previous_text:
|
||||
similarity = self._calculate_similarity(previous_text, text)
|
||||
else:
|
||||
similarity = 0.0
|
||||
|
||||
# Detect if new prompt
|
||||
is_new_prompt = similarity < similarity_threshold
|
||||
|
||||
# Update previous text
|
||||
self.previous_text[node_id] = text
|
||||
|
||||
if is_new_prompt:
|
||||
print(f"[SmartTextInput] New prompt detected (similarity: {similarity:.2f})")
|
||||
else:
|
||||
print(f"[SmartTextInput] Minor edit detected (similarity: {similarity:.2f})")
|
||||
|
||||
return (text, is_new_prompt, similarity)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Always re-execute to detect changes"""
|
||||
import random
|
||||
return random.random()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Utility functions for ComfyUI Prompting System"""
|
||||
|
||||
from .path_utils import get_database_path, ensure_directory_exists
|
||||
from .metadata_extractor import extract_prompt_from_png, extract_workflow_from_png
|
||||
|
||||
__all__ = [
|
||||
'get_database_path',
|
||||
'ensure_directory_exists',
|
||||
'extract_prompt_from_png',
|
||||
'extract_workflow_from_png'
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Extract metadata and prompts from ComfyUI generated images"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
|
||||
def extract_prompt_from_png(image_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract prompt information from ComfyUI generated PNG file.
|
||||
|
||||
Args:
|
||||
image_path: Path to the PNG file
|
||||
|
||||
Returns:
|
||||
Dictionary with prompt data or None if not found
|
||||
"""
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
|
||||
# ComfyUI stores prompt in 'prompt' metadata field
|
||||
if 'prompt' in img.info:
|
||||
prompt_data = json.loads(img.info['prompt'])
|
||||
return prompt_data
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error extracting prompt from {image_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_workflow_from_png(image_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract workflow information from ComfyUI generated PNG file.
|
||||
|
||||
Args:
|
||||
image_path: Path to the PNG file
|
||||
|
||||
Returns:
|
||||
Dictionary with workflow data or None if not found
|
||||
"""
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
|
||||
# ComfyUI stores workflow in 'workflow' metadata field
|
||||
if 'workflow' in img.info:
|
||||
workflow_data = json.loads(img.info['workflow'])
|
||||
return workflow_data
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error extracting workflow from {image_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_all_text_from_prompt_data(prompt_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Extract all text prompts from prompt data structure.
|
||||
Concatenates all text inputs found in the workflow.
|
||||
|
||||
Args:
|
||||
prompt_data: Prompt data dictionary from PNG metadata
|
||||
|
||||
Returns:
|
||||
Concatenated prompt text
|
||||
"""
|
||||
texts = []
|
||||
|
||||
try:
|
||||
# Prompt data structure: {node_id: {class_type, inputs: {...}}}
|
||||
for node_id, node_data in prompt_data.items():
|
||||
if isinstance(node_data, dict) and 'inputs' in node_data:
|
||||
inputs = node_data['inputs']
|
||||
|
||||
# Look for text inputs
|
||||
for key, value in inputs.items():
|
||||
if isinstance(value, str) and key in ['text', 'prompt', 'positive', 'negative']:
|
||||
if value.strip():
|
||||
texts.append(value.strip())
|
||||
|
||||
return ', '.join(texts)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error extracting text from prompt data: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def get_image_creation_time(image_path: str) -> Optional[float]:
|
||||
"""
|
||||
Get image file creation time.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file
|
||||
|
||||
Returns:
|
||||
Timestamp or None
|
||||
"""
|
||||
try:
|
||||
path = Path(image_path)
|
||||
if path.exists():
|
||||
return path.stat().st_mtime
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error getting creation time: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def scan_output_directory(output_dir: Path, limit: int = 100) -> list:
|
||||
"""
|
||||
Scan output directory for recent images.
|
||||
|
||||
Args:
|
||||
output_dir: Path to output directory
|
||||
limit: Maximum number of images to return
|
||||
|
||||
Returns:
|
||||
List of tuples (image_path, timestamp) sorted by newest first
|
||||
"""
|
||||
images = []
|
||||
|
||||
try:
|
||||
# Supported image formats
|
||||
extensions = ['.png', '.jpg', '.jpeg', '.webp']
|
||||
|
||||
for ext in extensions:
|
||||
for img_path in output_dir.glob(f'*{ext}'):
|
||||
timestamp = img_path.stat().st_mtime
|
||||
images.append((str(img_path), timestamp))
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
images.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
return images[:limit]
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PromptSystem] Error scanning output directory: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Cross-platform path utilities for database and file management"""
|
||||
|
||||
import os
|
||||
import folder_paths
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_comfy_dir() -> Path:
|
||||
"""Get ComfyUI root directory"""
|
||||
# folder_paths is ComfyUI's built-in module
|
||||
if hasattr(folder_paths, 'base_path'):
|
||||
return Path(folder_paths.base_path)
|
||||
|
||||
# Fallback: try to detect from common locations
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (parent / 'comfy').exists() or (parent / 'nodes.py').exists():
|
||||
return parent
|
||||
|
||||
# Last resort: use current working directory
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def get_database_path() -> Path:
|
||||
"""
|
||||
Get the database file path.
|
||||
Returns: Path to prompt_database.json in ./user/default/workflows/
|
||||
"""
|
||||
comfy_dir = get_comfy_dir()
|
||||
db_dir = comfy_dir / "user" / "default" / "workflows"
|
||||
ensure_directory_exists(db_dir)
|
||||
|
||||
return db_dir / "prompt_database.json"
|
||||
|
||||
|
||||
def get_output_dir() -> Path:
|
||||
"""Get ComfyUI output directory"""
|
||||
comfy_dir = get_comfy_dir()
|
||||
output_dir = comfy_dir / "output"
|
||||
|
||||
if not output_dir.exists():
|
||||
# Try alternate locations
|
||||
if hasattr(folder_paths, 'get_output_directory'):
|
||||
return Path(folder_paths.get_output_directory())
|
||||
output_dir = comfy_dir / "outputs"
|
||||
|
||||
return output_dir
|
||||
|
||||
|
||||
def get_input_dir() -> Path:
|
||||
"""Get ComfyUI input directory"""
|
||||
comfy_dir = get_comfy_dir()
|
||||
input_dir = comfy_dir / "input"
|
||||
|
||||
if not input_dir.exists():
|
||||
if hasattr(folder_paths, 'get_input_directory'):
|
||||
return Path(folder_paths.get_input_directory())
|
||||
|
||||
return input_dir
|
||||
|
||||
|
||||
def ensure_directory_exists(path: Path) -> None:
|
||||
"""
|
||||
Ensure a directory exists, create if it doesn't.
|
||||
Cross-platform safe.
|
||||
"""
|
||||
path = Path(path)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""
|
||||
Normalize path for cross-platform compatibility.
|
||||
Always uses forward slashes for storage in JSON.
|
||||
"""
|
||||
return str(Path(path).as_posix())
|
||||
|
||||
|
||||
def get_relative_path(absolute_path: Path, base_path: Optional[Path] = None) -> str:
|
||||
"""
|
||||
Convert absolute path to relative path from base.
|
||||
Returns normalized string with forward slashes.
|
||||
"""
|
||||
if base_path is None:
|
||||
base_path = get_comfy_dir()
|
||||
|
||||
try:
|
||||
rel_path = Path(absolute_path).relative_to(base_path)
|
||||
return normalize_path(rel_path)
|
||||
except ValueError:
|
||||
# Path is not relative to base, return as-is
|
||||
return normalize_path(absolute_path)
|
||||
|
||||
|
||||
def resolve_path(relative_path: str, base_path: Optional[Path] = None) -> Path:
|
||||
"""
|
||||
Resolve a relative path to absolute path.
|
||||
Handles both forward and backward slashes.
|
||||
"""
|
||||
if base_path is None:
|
||||
base_path = get_comfy_dir()
|
||||
|
||||
# Convert to Path (handles both slash types)
|
||||
path = Path(relative_path)
|
||||
|
||||
if path.is_absolute():
|
||||
return path
|
||||
|
||||
return (base_path / path).resolve()
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Prompt Library Styles
|
||||
* Custom styling for Prompting System nodes
|
||||
*/
|
||||
|
||||
/* Prompt Saver Node */
|
||||
.comfy-node.PromptSaver {
|
||||
border: 2px solid #6b6bff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.comfy-node.PromptSaver .locked-indicator {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.comfy-node.PromptSaver .locked-indicator.unlocked {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smart Text Input Node */
|
||||
.comfy-node.SmartTextInput {
|
||||
border: 2px solid #4ecdc4;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.comfy-node.SmartTextInput.new-prompt {
|
||||
animation: flash-red 0.5s ease-out;
|
||||
}
|
||||
|
||||
.comfy-node.SmartTextInput.edit-prompt {
|
||||
animation: flash-blue 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes flash-red {
|
||||
0% { background-color: rgba(255, 107, 107, 0.3); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
@keyframes flash-blue {
|
||||
0% { background-color: rgba(78, 205, 196, 0.3); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
/* Library Browser Node */
|
||||
.comfy-node.PromptLibraryBrowser {
|
||||
border: 2px solid #ffd700;
|
||||
border-radius: 8px;
|
||||
min-width: 600px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.prompt-library-container {
|
||||
padding: 16px;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.prompt-library-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #ffd700;
|
||||
}
|
||||
|
||||
.prompt-library-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.prompt-library-stats {
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.prompt-search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.prompt-search-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: #16213e;
|
||||
border: 1px solid #4a4a6a;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.prompt-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.prompt-filter-button {
|
||||
padding: 8px 16px;
|
||||
background: #4a4a6a;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.prompt-filter-button:hover {
|
||||
background: #6a6a8a;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.prompt-filter-button.active {
|
||||
background: #ffd700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.prompt-results {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.prompt-result-item {
|
||||
background: #16213e;
|
||||
border: 1px solid #4a4a6a;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.prompt-result-item:hover {
|
||||
border-color: #ffd700;
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.2);
|
||||
}
|
||||
|
||||
.prompt-result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prompt-result-id {
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.prompt-result-rating {
|
||||
color: #ffaa00;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.prompt-result-category {
|
||||
display: inline-block;
|
||||
background: #4a4a6a;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.prompt-result-text {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prompt-result-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.prompt-tag {
|
||||
background: #2a2a4a;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
color: #a0a0ff;
|
||||
}
|
||||
|
||||
.prompt-result-thumbnails {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.prompt-thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prompt-thumbnail:hover {
|
||||
border-color: #ffd700;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.prompt-thumbnail.locked {
|
||||
border-color: #ff6b6b;
|
||||
box-shadow: 0 0 8px rgba(255, 107, 107, 0.5);
|
||||
}
|
||||
|
||||
.prompt-result-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.prompt-action-button {
|
||||
padding: 6px 12px;
|
||||
background: #4a4a6a;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.prompt-action-button:hover {
|
||||
background: #6a6a8a;
|
||||
}
|
||||
|
||||
.prompt-action-button.primary {
|
||||
background: #ffd700;
|
||||
color: #1a1a2e;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.prompt-action-button.primary:hover {
|
||||
background: #ffed4e;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.prompt-action-button.danger {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
.prompt-action-button.danger:hover {
|
||||
background: #ff5252;
|
||||
}
|
||||
|
||||
/* Export Manager Node */
|
||||
.comfy-node.ExportManager {
|
||||
border: 2px solid #6bff6b;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.export-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.prompt-results::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.prompt-results::-webkit-scrollbar-track {
|
||||
background: #16213e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.prompt-results::-webkit-scrollbar-thumb {
|
||||
background: #4a4a6a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.prompt-results::-webkit-scrollbar-thumb:hover {
|
||||
background: #6a6a8a;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 215, 0, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #ffd700;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.prompt-toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #16213e;
|
||||
border: 2px solid #ffd700;
|
||||
border-radius: 8px;
|
||||
padding: 16px 24px;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: slide-in 0.3s ease-out;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-toast.success {
|
||||
border-color: #6bff6b;
|
||||
}
|
||||
|
||||
.prompt-toast.error {
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.prompt-toast.info {
|
||||
border-color: #4ecdc4;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Prompt Library UI - Enhanced browser interface
|
||||
* Provides rich UI for browsing, searching, and managing prompts
|
||||
*/
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
|
||||
// Custom widget for displaying prompt library
|
||||
class PromptLibraryWidget {
|
||||
constructor(node, inputName, inputData, app) {
|
||||
this.node = node;
|
||||
this.name = inputName;
|
||||
this.type = "custom_prompt_library";
|
||||
|
||||
// Create widget element
|
||||
const widget = {
|
||||
type: this.type,
|
||||
name: inputName,
|
||||
size: [600, 400],
|
||||
draw: function(ctx, node, widgetWidth, widgetY, height) {
|
||||
// Custom drawing if needed
|
||||
},
|
||||
computeSize: function(width) {
|
||||
return [width, 400];
|
||||
},
|
||||
value: ""
|
||||
};
|
||||
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom widget
|
||||
ComfyWidgets.PROMPTLIBRARY = function(node, inputName, inputData, app) {
|
||||
return new PromptLibraryWidget(node, inputName, inputData, app);
|
||||
};
|
||||
|
||||
// Extension for Prompt Saver Node
|
||||
app.registerExtension({
|
||||
name: "PromptingSystem.PromptSaver",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "PromptSaver") {
|
||||
// Add custom styling for lock button
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Find locked widget and add pulsing animation
|
||||
const lockedWidget = this.widgets?.find(w => w.name === "locked");
|
||||
|
||||
if (lockedWidget) {
|
||||
const originalDraw = lockedWidget.draw;
|
||||
|
||||
lockedWidget.draw = function(ctx, node, widgetWidth, y, height) {
|
||||
// Call original draw
|
||||
if (originalDraw) {
|
||||
originalDraw.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Add pulsing effect when unlocked
|
||||
if (!this.value) {
|
||||
const time = Date.now() / 1000;
|
||||
const pulse = Math.sin(time * 3) * 0.3 + 0.7;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = pulse;
|
||||
ctx.fillStyle = "#ff6b6b";
|
||||
ctx.fillRect(node.pos[0], node.pos[1] + y - 5, widgetWidth, height + 10);
|
||||
ctx.restore();
|
||||
|
||||
// Request redraw for animation
|
||||
node.graph?.setDirtyCanvas(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Extension for Prompt Library Browser
|
||||
app.registerExtension({
|
||||
name: "PromptingSystem.LibraryBrowser",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "PromptLibraryBrowser") {
|
||||
// Enhance node with custom UI
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Make node larger by default
|
||||
this.size = [600, 400];
|
||||
|
||||
// Add custom title
|
||||
this.title = "📚 Prompt Library";
|
||||
this.color = "#2a2a4a";
|
||||
this.bgcolor = "#1a1a2a";
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
// Handle results display
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
if (onExecuted) {
|
||||
onExecuted.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Parse and display results
|
||||
if (message?.text && message.text[2]) {
|
||||
const resultsText = message.text[2];
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
const results = JSON.parse(resultsText);
|
||||
|
||||
if (Array.isArray(results)) {
|
||||
console.log(`[Prompt Library] Loaded ${results.length} prompts`);
|
||||
|
||||
// Could create custom UI here
|
||||
this.displayResults = results;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, just text info
|
||||
console.log(`[Prompt Library] ${resultsText}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Extension for Smart Text Input
|
||||
app.registerExtension({
|
||||
name: "PromptingSystem.SmartTextInput",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "SmartTextInput") {
|
||||
// Add visual indicator for change detection
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
if (onExecuted) {
|
||||
onExecuted.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Show change detection result
|
||||
if (message?.text) {
|
||||
const isNewPrompt = message.text[1]; // boolean
|
||||
const similarity = message.text[2]; // float
|
||||
|
||||
// Update node color based on detection
|
||||
if (isNewPrompt) {
|
||||
this.color = "#4a2a2a"; // Red tint for new prompt
|
||||
this.bgcolor = "#2a1a1a";
|
||||
console.log(`[Smart Text] New prompt detected (similarity: ${similarity.toFixed(2)})`);
|
||||
} else {
|
||||
this.color = "#2a2a4a"; // Blue tint for edit
|
||||
this.bgcolor = "#1a1a2a";
|
||||
console.log(`[Smart Text] Edit detected (similarity: ${similarity.toFixed(2)})`);
|
||||
}
|
||||
|
||||
// Reset color after 2 seconds
|
||||
setTimeout(() => {
|
||||
this.color = "#222";
|
||||
this.bgcolor = "#000";
|
||||
app.graph?.setDirtyCanvas(true);
|
||||
}, 2000);
|
||||
|
||||
app.graph?.setDirtyCanvas(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Extension for Export Manager
|
||||
app.registerExtension({
|
||||
name: "PromptingSystem.ExportManager",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "ExportManager") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Style node
|
||||
this.title = "📤 Export Manager";
|
||||
this.color = "#2a4a2a";
|
||||
this.bgcolor = "#1a2a1a";
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("🎨 Prompt Library UI extensions loaded!");
|
||||
Reference in New Issue
Block a user