Merge pull request #80 from ComfyAssets/feature/AutoTag

Feature/auto tag
This commit is contained in:
Vito
2025-12-07 17:39:20 -08:00
committed by GitHub
7 changed files with 2872 additions and 33 deletions
+59 -1
View File
@@ -56,6 +56,7 @@ 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
![Image Gallery](images/pm-02.png)
@@ -363,6 +364,53 @@ Import existing ComfyUI images into your database:
- Any errors or issues encountered
5. **Access imported data** through the normal gallery interface
### 🏷️ AI AutoTag
Automatically tag your entire image collection using JoyCaption vision models:
#### **Model Options**
- **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
#### **Two Tagging Modes**
1. **AutoTag (Batch Mode)**: Tag your entire collection automatically
- Click **"🏷️ AutoTag"** in the admin dashboard
- Choose your model type (FP16 or GGUF)
- Select how to handle already-tagged images:
- **Skip images with existing tags**: Preserve your meticulous manual tagging work
- **Re-tag all images**: Overwrite existing tags with fresh AI analysis
- Customize the system prompt to match your collection style
- Monitor real-time progress as images are processed
2. **Review Mode**: Tag images one-by-one with approval
- Review each image and its AI-generated tags before applying
- Edit, add, or remove tags before saving
- Skip images you don't want to tag
- Perfect for curating high-quality tag assignments
#### **Handling Existing Tags**
For users who have meticulously tagged their collections from the start:
- AutoTag respects your existing work with the **"Skip tagged"** option
- **"auto-scanned"** placeholder tags don't count as real tags
- Choose to re-tag specific images while preserving others
- Review mode asks before overwriting on each image
#### **Customization**
Adjust the system prompt to match your tagging style:
- Focus on specific attributes (style, mood, composition, subjects)
- Match your existing tag vocabulary
- Optimize for your collection's theme (anime, photography, landscapes, etc.)
#### **Requirements**
- Sufficient VRAM for the chosen model (FP16 requires more, GGUF is lighter)
- Models are downloaded automatically on first use
- Feature requests and improvements are welcome!
### 🌐 Web Interface Features
The comprehensive web interface provides:
@@ -770,13 +818,23 @@ MIT License - see LICENSE file for details.
### Integration Ideas
- **Auto-tagging**: Use AI to automatically categorize prompts
- **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.0.23 (AI AutoTag Release)
- **🏷️ AI AutoTag**: Automatically tag your entire image collection using JoyCaption vision models
- **🤖 Dual Model Support**: Choose between JoyCaption Beta One FP16 (high quality) or GGUF FP8 (lower VRAM)
- **📋 Two Tagging Modes**: Batch mode for bulk tagging, Review mode for one-by-one approval
- **⚙️ Smart Tag Handling**: Skip already-tagged images or re-tag all - respects your existing work
- **✏️ Customizable Prompts**: Adjust the system prompt to match your collection style and tag vocabulary
- **🔄 Real-time Progress**: SSE-based streaming progress updates during batch operations
- **📦 Tag Accordion UI**: Collapsible tag display shows first row with expandable section for large tag sets
- **🎯 Review Confirmation**: Prompts before overwriting existing tags in review mode
### v3.0.10 (Batch Processing Node)
- **📋 PromptSearchList Node**: New node that searches the prompt database and outputs results as a list
+57 -9
View File
@@ -195,7 +195,7 @@ class PromptDatabase:
params.extend([limit, offset])
query = " ".join(query_parts)
with self.model.get_connection() as conn:
cursor = conn.execute(query, params)
rows = cursor.fetchall()
@@ -368,12 +368,12 @@ class PromptDatabase:
def get_all_tags(self) -> List[str]:
"""
Get all unique tags from the database.
Returns:
List of tag names
"""
all_tags = set()
with self.model.get_connection() as conn:
cursor = conn.execute("SELECT tags FROM prompts WHERE tags IS NOT NULL")
for row in cursor.fetchall():
@@ -381,32 +381,43 @@ class PromptDatabase:
tags = json.loads(row['tags'])
if isinstance(tags, list):
all_tags.update(tags)
elif isinstance(tags, str):
# Handle corrupted data (comma-separated string stored as JSON)
parsed_tags = [t.strip() for t in tags.split(',') if t.strip()]
all_tags.update(parsed_tags)
except (json.JSONDecodeError, TypeError):
continue
return sorted(list(all_tags))
def _row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]:
"""
Convert a database row to a dictionary with parsed JSON fields.
Args:
row: SQLite row object
Returns:
Dictionary representation of the row
"""
data = dict(row)
# Parse tags JSON
if data.get('tags'):
try:
data['tags'] = json.loads(data['tags'])
parsed = json.loads(data['tags'])
if isinstance(parsed, list):
data['tags'] = parsed
elif isinstance(parsed, str):
# Handle corrupted data (comma-separated string stored as JSON)
data['tags'] = [t.strip() for t in parsed.split(',') if t.strip()]
else:
data['tags'] = []
except (json.JSONDecodeError, TypeError):
data['tags'] = []
else:
data['tags'] = []
return data
def export_prompts(self, file_path: str, format: str = "json") -> bool:
@@ -715,6 +726,43 @@ class PromptDatabase:
)
return [self._image_row_to_dict(row) for row in cursor.fetchall()]
def get_all_images(self) -> List[Dict[str, Any]]:
"""
Get all generated images with their linked prompts.
Returns:
List of all image records with prompt text and tags
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"""
SELECT gi.*, p.text as prompt_text, p.tags as prompt_tags
FROM generated_images gi
LEFT JOIN prompts p ON gi.prompt_id = p.id
ORDER BY gi.generation_time DESC
"""
)
rows = cursor.fetchall()
result = []
for row in rows:
data = self._image_row_to_dict(row)
# Parse prompt_tags JSON
if row['prompt_tags']:
try:
tags = json.loads(row['prompt_tags'])
if isinstance(tags, list):
data['prompt_tags'] = tags
elif isinstance(tags, str):
data['prompt_tags'] = [t.strip() for t in tags.split(',') if t.strip()]
else:
data['prompt_tags'] = []
except (json.JSONDecodeError, TypeError):
data['prompt_tags'] = []
else:
data['prompt_tags'] = []
result.append(data)
return result
def search_images_by_prompt(self, search_term: str) -> List[Dict[str, Any]]:
"""
Search images by prompt text.
+511 -4
View File
@@ -374,6 +374,10 @@ class PromptManagerAPI:
async def get_recent_images_route(request):
return await self.get_recent_images(request)
@routes.get("/prompt_manager/images/all")
async def get_all_images_route(request):
return await self.get_all_images(request)
@routes.get("/prompt_manager/images/search")
async def search_images_route(request):
return await self.search_images(request)
@@ -457,6 +461,31 @@ class PromptManagerAPI:
async def get_log_stats_route(request):
return await self.get_log_stats(request)
# AutoTag endpoints
@routes.get("/prompt_manager/autotag/models")
async def get_autotag_models_route(request):
return await self.get_autotag_models(request)
@routes.get("/prompt_manager/autotag/download/{model_type}")
async def download_autotag_model_route(request):
return await self.download_autotag_model(request)
@routes.get("/prompt_manager/autotag/start")
async def start_autotag_route(request):
return await self.start_autotag(request)
@routes.post("/prompt_manager/autotag/single")
async def autotag_single_route(request):
return await self.autotag_single(request)
@routes.post("/prompt_manager/autotag/apply")
async def apply_autotag_route(request):
return await self.apply_autotag(request)
@routes.get("/prompt_manager/scan_output_dir")
async def scan_output_dir_route(request):
return await self.scan_output_dir(request)
self.logger.info("All routes registered with decorator pattern")
async def search_prompts(self, request):
@@ -1754,7 +1783,7 @@ class PromptManagerAPI:
try:
limit = int(request.query.get('limit', 50))
images = self.db.get_recent_images(limit)
return web.json_response({
'success': True,
'images': images
@@ -1766,6 +1795,23 @@ class PromptManagerAPI:
'error': str(e)
}, status=500)
async def get_all_images(self, request):
"""Get all generated images with linked prompts."""
try:
images = self.db.get_all_images()
return web.json_response({
'success': True,
'images': images,
'count': len(images)
})
except Exception as e:
self.logger.error(f"Get all images error: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def search_images(self, request):
"""Search images by prompt text."""
try:
@@ -4195,18 +4241,479 @@ class PromptManagerAPI:
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, current_dir)
from utils.logging_config import get_logger_manager
logger_manager = get_logger_manager()
stats = logger_manager.get_log_stats()
return web.json_response({
'success': True,
'stats': stats
})
except Exception as e:
self.logger.error(f"Get log stats error: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
# ========================================================================
# AutoTag Endpoints
# ========================================================================
async def get_autotag_models(self, request):
"""
Get status of available AutoTag models.
GET /prompt_manager/autotag/models
Returns model availability, download status, and configuration.
"""
try:
from .autotag import get_autotag_service
service = get_autotag_service()
models_status = service.get_models_status()
return web.json_response({
'success': True,
'models': models_status,
'default_prompt': service.default_prompt
})
except Exception as e:
self.logger.error(f"Get autotag models error: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def download_autotag_model(self, request):
"""
Download an AutoTag model with streaming progress.
POST /prompt_manager/autotag/download/{model_type}
Streams SSE progress updates during download.
"""
import json
import asyncio
model_type = request.match_info.get('model_type')
async def stream_response():
try:
from .autotag import get_autotag_service
service = get_autotag_service()
if model_type not in service.models_config:
yield f"data: {json.dumps({'type': 'error', 'message': f'Invalid model type: {model_type}'})}\n\n"
return
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'status': 'Starting download...'})}\n\n"
# Define progress callback
progress_data = {'last_progress': 0}
def progress_callback(status: str, progress: float):
progress_data['last_progress'] = progress
# Perform download (blocking in thread)
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(
None,
lambda: service.download_model(model_type, progress_callback)
)
if success:
yield f"data: {json.dumps({'type': 'complete', 'progress': 100, 'status': 'Download complete'})}\n\n"
else:
yield f"data: {json.dumps({'type': 'error', 'message': 'Download failed'})}\n\n"
except Exception as e:
self.logger.error(f"Download model error: {e}")
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
)
await response.prepare(request)
async for chunk in stream_response():
await response.write(chunk.encode('utf-8'))
await response.write_eof()
return response
async def start_autotag(self, request):
"""
Start batch auto-tagging with streaming progress.
GET /prompt_manager/autotag/start
Query params:
model_type: "gguf" or "hf"
prompt: custom prompt text
Streams SSE progress updates during processing.
"""
import json
import asyncio
from pathlib import Path
# Read from query params (EventSource only supports GET)
model_type = request.query.get('model_type', 'gguf')
custom_prompt = request.query.get('prompt', '')
skip_tagged = request.query.get('skip_tagged', 'true').lower() == 'true'
use_gpu = True
async def stream_response():
try:
from .autotag import get_autotag_service
service = get_autotag_service()
# Check model is downloaded
status = service.get_models_status()
if not status.get(model_type, {}).get('downloaded'):
yield f"data: {json.dumps({'type': 'error', 'message': f'Model {model_type} not downloaded'})}\n\n"
return
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'status': 'Loading model...'})}\n\n"
# Load model in thread pool
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
lambda: service.load_model(model_type, use_gpu)
)
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': f'Failed to load model: {str(e)}'})}\n\n"
return
# Set custom prompt if provided
if custom_prompt:
service.custom_prompt = custom_prompt
yield f"data: {json.dumps({'type': 'progress', 'progress': 5, 'status': 'Model loaded. Fetching all images from database...'})}\n\n"
# Get ALL images from database (only images with linked prompts)
images = self.db.get_all_images()
total_files = len(images)
if total_files == 0:
yield f"data: {json.dumps({'type': 'complete', 'processed': 0, 'tagged': 0, 'skipped': 0, 'status': 'No images with linked prompts found'})}\n\n"
service.unload_model()
return
yield f"data: {json.dumps({'type': 'progress', 'progress': 10, 'status': f'Found {total_files} images. Processing...'})}\n\n"
processed = 0
tagged = 0
skipped = 0
errors = 0
for i, image_data in enumerate(images):
image_path = image_data.get('image_path')
prompt_id = image_data.get('prompt_id')
if not image_path or not prompt_id:
skipped += 1
continue
# Check if file exists
if not Path(image_path).exists():
skipped += 1
continue
# Check if image already has real tags (skip_tagged option)
if skip_tagged:
prompt_tags = image_data.get('prompt_tags', [])
if isinstance(prompt_tags, str):
prompt_tags = [t.strip() for t in prompt_tags.split(',') if t.strip()]
# Filter out "auto-scanned" - it's not a real tag
real_tags = [t for t in prompt_tags if t != 'auto-scanned']
if real_tags:
skipped += 1
if i % 5 == 0 or i == total_files - 1:
progress = 10 + int((i + 1) / total_files * 85)
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (already tagged)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
await asyncio.sleep(0.01)
continue
try:
# Generate tags
tags = await loop.run_in_executor(
None,
lambda p=str(image_path): service.generate_tags(p)
)
processed += 1
if tags:
# Get existing prompt
existing_prompt = self.db.get_prompt_by_id(prompt_id)
if existing_prompt:
existing_tags = existing_prompt.get('tags', [])
if isinstance(existing_tags, str):
existing_tags = [t.strip() for t in existing_tags.split(',') if t.strip()]
new_tags = [t for t in tags if t not in existing_tags]
if new_tags:
all_tags = existing_tags + new_tags
self.db.update_prompt_metadata(
prompt_id,
tags=all_tags
)
tagged += 1
else:
skipped += 1
else:
skipped += 1
else:
skipped += 1
if i % 5 == 0 or i == total_files - 1:
progress = 10 + int((i + 1) / total_files * 85)
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing {i+1}/{total_files}...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
await asyncio.sleep(0.01)
except Exception as img_err:
self.logger.error(f"Error processing {image_path}: {img_err}")
errors += 1
processed += 1
service.unload_model()
yield f"data: {json.dumps({'type': 'complete', 'progress': 100, 'processed': processed, 'tagged': tagged, 'skipped': skipped, 'errors': errors, 'status': 'Complete'})}\n\n"
except Exception as e:
self.logger.error(f"AutoTag error: {e}")
import traceback
self.logger.error(f"AutoTag traceback: {traceback.format_exc()}")
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
)
await response.prepare(request)
async for chunk in stream_response():
await response.write(chunk.encode('utf-8'))
await response.write_eof()
return response
async def autotag_single(self, request):
"""
Generate tags for a single image (for Review mode).
POST /prompt_manager/autotag/single
Request body:
{
"image_path": "/path/to/image.png",
"model_type": "gguf",
"prompt": "optional custom prompt",
"use_gpu": true
}
"""
try:
data = await request.json()
image_path = data.get('image_path')
model_type = data.get('model_type', 'gguf')
custom_prompt = data.get('prompt')
use_gpu = data.get('use_gpu', True)
if not image_path:
return web.json_response({
'success': False,
'error': 'image_path is required'
}, status=400)
from .autotag import get_autotag_service
import asyncio
service = get_autotag_service()
if not service.is_model_loaded() or service.get_loaded_model_type() != model_type:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: service.load_model(model_type, use_gpu)
)
if custom_prompt:
service.custom_prompt = custom_prompt
loop = asyncio.get_event_loop()
tags = await loop.run_in_executor(
None,
lambda: service.generate_tags(image_path)
)
prompt_id = None
try:
with self.db.model.get_connection() as conn:
cursor = conn.execute(
"SELECT prompt_id FROM generated_images WHERE image_path = ?",
(image_path,)
)
row = cursor.fetchone()
if row:
prompt_id = row[0]
except Exception as e:
self.logger.warning(f"Could not find linked prompt: {e}")
return web.json_response({
'success': True,
'tags': tags,
'prompt_id': prompt_id,
'image_path': image_path
})
except Exception as e:
self.logger.error(f"AutoTag single error: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def apply_autotag(self, request):
"""
Apply selected tags to a prompt.
POST /prompt_manager/autotag/apply
Request body:
{
"prompt_id": 123,
"tags": ["tag1", "tag2", ...]
}
"""
try:
data = await request.json()
prompt_id = data.get('prompt_id')
tags = data.get('tags', [])
if not prompt_id:
return web.json_response({
'success': False,
'error': 'prompt_id is required'
}, status=400)
if not tags:
return web.json_response({
'success': True,
'message': 'No tags to apply'
})
prompt = self.db.get_prompt_by_id(prompt_id)
if not prompt:
return web.json_response({
'success': False,
'error': f'Prompt {prompt_id} not found'
}, status=404)
existing_tags = prompt.get('tags', [])
if isinstance(existing_tags, str):
existing_tags = [t.strip() for t in existing_tags.split(',') if t.strip()]
new_tags = [t for t in tags if t not in existing_tags]
all_tags = existing_tags + new_tags
self.db.update_prompt_metadata(
prompt_id,
tags=all_tags
)
return web.json_response({
'success': True,
'added_tags': new_tags,
'total_tags': len(all_tags)
})
except Exception as e:
self.logger.error(f"Apply autotag error: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def scan_output_dir(self, request):
"""
Scan ComfyUI output directory for images.
GET /prompt_manager/scan_output_dir
Returns a list of images in the output directory for autotag review mode.
"""
from pathlib import Path
try:
output_dir = self._find_comfyui_output_dir()
if not output_dir:
return web.json_response({
'success': False,
'error': 'ComfyUI output directory not found'
}, status=404)
output_path = Path(output_dir)
image_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff']
images = []
seen_paths = set()
for ext in image_extensions:
for pattern in [f"*{ext.lower()}", f"*{ext.upper()}"]:
for image_path in output_path.rglob(pattern):
if 'thumbnails' not in image_path.parts:
normalized_path = str(image_path).lower()
if normalized_path not in seen_paths:
seen_paths.add(normalized_path)
rel_path = image_path.relative_to(output_path)
# Check for thumbnail
thumbnail_url = None
thumbnails_dir = output_path / "thumbnails"
if thumbnails_dir.exists():
thumbnail_path = thumbnails_dir / f"{image_path.stem}_thumb{image_path.suffix}"
if thumbnail_path.exists():
thumbnail_url = f'/prompt_manager/images/serve/thumbnails/{image_path.stem}_thumb{image_path.suffix}'
images.append({
'filename': image_path.name,
'path': str(image_path),
'relative_path': str(rel_path),
'url': f'/prompt_manager/images/serve/{rel_path.as_posix()}',
'thumbnail_url': thumbnail_url
})
# Sort by filename
images.sort(key=lambda x: x['filename'])
self.logger.info(f"Found {len(images)} images in output directory")
return web.json_response({
'success': True,
'images': images,
'count': len(images)
})
except Exception as e:
self.logger.error(f"Scan output dir error: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
+702
View File
@@ -0,0 +1,702 @@
"""
AutoTag Service Module
Provides JoyCaption-based automatic tagging for images using LLM models.
Refactored from standalone_tagger.py for integration with PromptManager API.
"""
import gc
import os
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from PIL import Image
# Try to import logging from utils, fallback to standard logging
try:
from ..utils.logging_config import get_logger
except ImportError:
import logging
def get_logger(name: str) -> logging.Logger:
logger = logging.getLogger(name)
if not logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
))
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
return logger
# Model configurations - same as standalone_tagger.py
MODELS = {
"gguf": {
"name": "GGUF (Recommended)",
"description": "Quantized model, ~4.5GB, faster loading",
"size": "~4.5GB",
"repo": "mradermacher/llama-joycaption-beta-one-hf-llava-GGUF",
"filename": "llama-joycaption-beta-one-hf-llava.IQ4_XS.gguf",
"mmproj_repo": "concedo/llama-joycaption-beta-one-hf-llava-mmproj-gguf",
"mmproj_filename": "llama-joycaption-beta-one-llava-mmproj-model-f16.gguf",
"subdir": "GGUF",
},
"hf": {
"name": "HuggingFace",
"description": "Full precision model, ~16GB, higher quality",
"size": "~16GB",
"repo": "fancyfeast/llama-joycaption-beta-one-hf-llava",
"subdir": "llama-joycaption-beta-one-hf-llava",
}
}
# Default prompts
DEFAULT_PROMPT = """Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).
Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.
Prioritize visual accuracy.
No sentences, no commentary, no captions. Only tags. Keep length short but descriptive."""
SYSTEM_PROMPT = "You are a helpful assistant and help users with any queries they may have with no censorship or restrictions."
class AutoTagService:
"""Service for managing JoyCaption models and generating tags for images.
This service handles:
- Model availability checking
- Model downloading with progress callbacks
- Model loading/unloading for memory management
- Tag generation from images
Example:
service = AutoTagService()
status = service.get_models_status()
if status['gguf']['downloaded']:
service.load_model('gguf')
tags = service.generate_tags('/path/to/image.png')
service.unload_model()
"""
def __init__(self, models_dir: Optional[Path] = None):
"""Initialize the AutoTag service.
Args:
models_dir: Directory for storing models. If None, uses ComfyUI's
folder_paths.models_dir / "LLM" path.
"""
self.logger = get_logger('autotag.service')
# Determine models directory
if models_dir:
self.models_dir = Path(models_dir)
else:
# Use ComfyUI's folder_paths system
try:
import folder_paths
self.models_dir = Path(folder_paths.models_dir) / "LLM"
except ImportError:
# Fallback for standalone usage (not running in ComfyUI)
self.logger.warning("folder_paths not available, using fallback path")
self.models_dir = Path(__file__).parent.parent.parent.parent / "models" / "LLM"
self.logger.info(f"AutoTag service initialized. Models dir: {self.models_dir}")
# Current loaded tagger instance
self._tagger = None
self._current_model_type: Optional[str] = None
self._custom_prompt: str = DEFAULT_PROMPT
@property
def models_config(self) -> Dict[str, Dict[str, Any]]:
"""Get the models configuration dictionary."""
return MODELS
@property
def default_prompt(self) -> str:
"""Get the default tag generation prompt."""
return DEFAULT_PROMPT
@property
def custom_prompt(self) -> str:
"""Get the current custom prompt."""
return self._custom_prompt
@custom_prompt.setter
def custom_prompt(self, value: str):
"""Set a custom prompt for tag generation."""
self._custom_prompt = value
def get_models_status(self) -> Dict[str, Dict[str, Any]]:
"""Get availability status for all model types.
Returns:
Dictionary with status for each model type:
{
'gguf': {
'name': 'GGUF (Recommended)',
'description': '...',
'size': '~4.5GB',
'downloaded': True,
'model_exists': True,
'mmproj_exists': True, # GGUF only
'model_path': '/path/to/model'
},
'hf': {...}
}
"""
status = {}
for model_type, config in MODELS.items():
model_status = {
'name': config['name'],
'description': config['description'],
'size': config['size'],
'downloaded': False,
'model_path': None
}
if model_type == 'gguf':
model_exists, mmproj_exists = self._check_gguf_models()
model_status['model_exists'] = model_exists
model_status['mmproj_exists'] = mmproj_exists
model_status['downloaded'] = model_exists and mmproj_exists
if model_status['downloaded']:
model_status['model_path'] = str(
self.models_dir / config['subdir'] / config['filename']
)
else: # hf
model_status['downloaded'] = self._check_hf_model()
if model_status['downloaded']:
# Get the actual path (local or cache)
model_status['model_path'] = str(
self._get_hf_model_path()
)
status[model_type] = model_status
return status
def _check_gguf_models(self) -> Tuple[bool, bool]:
"""Check if GGUF model and mmproj files exist.
Returns:
Tuple of (model_exists, mmproj_exists)
"""
config = MODELS['gguf']
gguf_dir = self.models_dir / config['subdir']
model_path = gguf_dir / config['filename']
mmproj_path = gguf_dir / config['mmproj_filename']
return model_path.exists(), mmproj_path.exists()
def _check_hf_model(self) -> bool:
"""Check if HuggingFace model exists.
Checks both the local models directory and the HuggingFace cache.
Returns:
True if model directory contains config.json
"""
config = MODELS['hf']
# Check local directory first
model_dir = self.models_dir / config['subdir']
self.logger.debug(f"Checking local HF model path: {model_dir}")
if (model_dir / "config.json").exists():
self.logger.debug("Found HF model in local directory")
return True
# Check HuggingFace cache as fallback
hf_cache_path = self._get_hf_cache_path(config['repo'])
if hf_cache_path:
self.logger.debug(f"Found HF model in cache: {hf_cache_path}")
return True
self.logger.debug("HF model not found in local dir or cache")
return False
def _get_hf_cache_path(self, repo_id: str) -> Optional[Path]:
"""Get the path to a model in the HuggingFace cache.
Args:
repo_id: The HuggingFace repo ID (e.g., 'fancyfeast/llama-joycaption-beta-one-hf-llava')
Returns:
Path to the cached model directory, or None if not found
"""
try:
from huggingface_hub import scan_cache_dir, HFCacheInfo
except ImportError:
self.logger.debug("huggingface_hub not available for cache check")
return None
try:
cache_info = scan_cache_dir()
for repo in cache_info.repos:
if repo.repo_id == repo_id and repo.repo_type == "model":
# Get the latest revision's snapshot path
for revision in repo.revisions:
snapshot_path = revision.snapshot_path
if (Path(snapshot_path) / "config.json").exists():
return Path(snapshot_path)
except Exception as e:
self.logger.debug(f"Error scanning HF cache: {e}")
return None
def _get_hf_model_path(self) -> Optional[Path]:
"""Get the actual path to the HuggingFace model.
Checks local directory first, then HuggingFace cache.
Returns:
Path to the model directory, or None if not found
"""
config = MODELS['hf']
# Check local directory first
model_dir = self.models_dir / config['subdir']
if (model_dir / "config.json").exists():
return model_dir
# Check HuggingFace cache as fallback
cache_path = self._get_hf_cache_path(config['repo'])
if cache_path:
return cache_path
return None
def download_model(
self,
model_type: str,
progress_callback: Optional[Callable[[str, float], None]] = None
) -> bool:
"""Download a model with optional progress updates.
Args:
model_type: Either 'gguf' or 'hf'
progress_callback: Optional callback(status_message, progress_percent)
Returns:
True if download successful, False otherwise
Raises:
ValueError: If model_type is not valid
"""
if model_type not in MODELS:
raise ValueError(f"Invalid model type: {model_type}. Must be 'gguf' or 'hf'")
try:
from huggingface_hub import hf_hub_download, snapshot_download
except ImportError:
self.logger.error("huggingface_hub not installed")
if progress_callback:
progress_callback("Error: huggingface_hub not installed", 0)
return False
try:
if model_type == 'gguf':
return self._download_gguf_models(progress_callback)
else:
return self._download_hf_model(progress_callback)
except Exception as e:
self.logger.error(f"Download failed: {e}")
if progress_callback:
progress_callback(f"Error: {str(e)}", 0)
return False
def _download_gguf_models(
self,
progress_callback: Optional[Callable[[str, float], None]] = None
) -> bool:
"""Download GGUF model and mmproj files."""
from huggingface_hub import hf_hub_download
config = MODELS['gguf']
gguf_dir = self.models_dir / config['subdir']
gguf_dir.mkdir(parents=True, exist_ok=True)
model_path = gguf_dir / config['filename']
mmproj_path = gguf_dir / config['mmproj_filename']
# Download main model
if not model_path.exists():
if progress_callback:
progress_callback(f"Downloading {config['filename']}...", 10)
self.logger.info(f"Downloading GGUF model: {config['filename']}")
hf_hub_download(
repo_id=config['repo'],
filename=config['filename'],
local_dir=str(gguf_dir),
local_dir_use_symlinks=False
)
self.logger.info("GGUF model downloaded")
if progress_callback:
progress_callback("Main model ready", 50)
# Download mmproj
if not mmproj_path.exists():
if progress_callback:
progress_callback(f"Downloading {config['mmproj_filename']}...", 60)
self.logger.info(f"Downloading mmproj: {config['mmproj_filename']}")
hf_hub_download(
repo_id=config['mmproj_repo'],
filename=config['mmproj_filename'],
local_dir=str(gguf_dir),
local_dir_use_symlinks=False
)
self.logger.info("mmproj downloaded")
if progress_callback:
progress_callback("Download complete", 100)
return True
def _download_hf_model(
self,
progress_callback: Optional[Callable[[str, float], None]] = None
) -> bool:
"""Download HuggingFace model."""
from huggingface_hub import snapshot_download
config = MODELS['hf']
model_dir = self.models_dir / config['subdir']
if not self._check_hf_model():
if progress_callback:
progress_callback(f"Downloading {config['repo']}...", 10)
self.logger.info(f"Downloading HF model: {config['repo']}")
snapshot_download(
repo_id=config['repo'],
local_dir=str(model_dir),
local_dir_use_symlinks=False
)
self.logger.info("HF model downloaded")
if progress_callback:
progress_callback("Download complete", 100)
return True
def load_model(self, model_type: str, use_gpu: bool = True) -> bool:
"""Load a model into memory for tag generation.
Args:
model_type: Either 'gguf' or 'hf'
use_gpu: Whether to use GPU acceleration (default True)
Returns:
True if model loaded successfully
Raises:
ValueError: If model_type is invalid
RuntimeError: If model not downloaded or loading fails
"""
if model_type not in MODELS:
raise ValueError(f"Invalid model type: {model_type}")
# Unload existing model first
if self._tagger is not None:
self.unload_model()
status = self.get_models_status()
if not status[model_type]['downloaded']:
raise RuntimeError(f"Model {model_type} not downloaded")
try:
if model_type == 'gguf':
self._tagger = self._load_gguf_tagger(use_gpu)
else:
self._tagger = self._load_hf_tagger()
self._current_model_type = model_type
self.logger.info(f"Model {model_type} loaded successfully")
return True
except Exception as e:
self.logger.error(f"Failed to load model {model_type}: {e}")
self._tagger = None
self._current_model_type = None
raise RuntimeError(f"Failed to load model: {e}")
def _load_gguf_tagger(self, use_gpu: bool = True):
"""Load GGUF-based tagger."""
from llama_cpp import Llama
from llama_cpp.llama_chat_format import Llava15ChatHandler
config = MODELS['gguf']
gguf_dir = self.models_dir / config['subdir']
model_path = gguf_dir / config['filename']
mmproj_path = gguf_dir / config['mmproj_filename']
self.logger.info("Loading GGUF model...")
n_gpu_layers = -1 if use_gpu else 0
tagger = Llama(
model_path=str(model_path),
n_ctx=4096,
n_batch=2048,
n_threads=4,
n_gpu_layers=n_gpu_layers,
verbose=False,
chat_handler=Llava15ChatHandler(clip_model_path=str(mmproj_path)),
offload_kqv=True,
)
self.logger.info("GGUF model loaded")
return ('gguf', tagger)
def _load_hf_tagger(self, quantization: str = "8bit"):
"""Load HuggingFace-based tagger."""
import torch
from transformers import AutoProcessor, LlavaForConditionalGeneration, BitsAndBytesConfig
# Get the actual model path (local or cache)
model_path = self._get_hf_model_path()
if model_path is None:
raise RuntimeError("HuggingFace model not found in local directory or cache")
self.logger.info(f"Loading HuggingFace model from {model_path}...")
device = "cuda" if torch.cuda.is_available() else "cpu"
processor = AutoProcessor.from_pretrained(str(model_path))
model_kwargs = {"device_map": "cuda" if device == "cuda" else "cpu"}
if quantization == "8bit":
qnt_config = BitsAndBytesConfig(
load_in_8bit=True,
bnb_8bit_compute_dtype=torch.float16,
bnb_8bit_use_double_quant=True,
llm_int8_skip_modules=["vision_tower", "multi_modal_projector"],
)
model = LlavaForConditionalGeneration.from_pretrained(
str(model_path),
torch_dtype=torch.float16,
quantization_config=qnt_config,
**model_kwargs
)
else:
model = LlavaForConditionalGeneration.from_pretrained(
str(model_path),
torch_dtype=torch.bfloat16,
**model_kwargs
)
model.eval()
self.logger.info("HuggingFace model loaded")
# Track the compute dtype for pixel_values conversion
compute_dtype = torch.float16 if quantization == "8bit" else torch.bfloat16
return ('hf', (model, processor, device, compute_dtype))
def unload_model(self):
"""Unload the current model and free memory."""
if self._tagger is not None:
self.logger.info(f"Unloading model: {self._current_model_type}")
del self._tagger
self._tagger = None
self._current_model_type = None
gc.collect()
# Try to clear CUDA cache if available
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
pass
self.logger.info("Model unloaded, memory freed")
def is_model_loaded(self) -> bool:
"""Check if a model is currently loaded."""
return self._tagger is not None
def get_loaded_model_type(self) -> Optional[str]:
"""Get the type of currently loaded model."""
return self._current_model_type
def generate_tags(
self,
image_path: str,
prompt: Optional[str] = None
) -> List[str]:
"""Generate tags for an image.
Args:
image_path: Path to the image file
prompt: Custom prompt for tag generation. Uses default if None.
Returns:
List of generated tags
Raises:
RuntimeError: If no model is loaded
FileNotFoundError: If image doesn't exist
"""
if self._tagger is None:
raise RuntimeError("No model loaded. Call load_model() first.")
if not os.path.exists(image_path):
raise FileNotFoundError(f"Image not found: {image_path}")
use_prompt = prompt or self._custom_prompt
# Load image
image = Image.open(image_path)
if image.mode != 'RGB':
image = image.convert('RGB')
# Generate based on model type
model_type, tagger_obj = self._tagger
if model_type == 'gguf':
raw_tags = self._generate_gguf(tagger_obj, image, use_prompt)
else:
model, processor, device, compute_dtype = tagger_obj
raw_tags = self._generate_hf(model, processor, device, compute_dtype, image, use_prompt)
# Parse tags from response
tags = self._parse_tags(raw_tags)
return tags
def _generate_gguf(self, model, image: Image.Image, prompt: str) -> str:
"""Generate tags using GGUF model."""
import base64
import io
# Resize image
image = image.resize((336, 336), Image.Resampling.BILINEAR)
# Encode to base64
buffer = io.BytesIO()
image.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.read()).decode('utf-8')
data_uri = f"data:image/png;base64,{img_base64}"
# Create message
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_uri}}
]
}
]
# Generate
response = model.create_chat_completion(
messages=messages,
max_tokens=512,
temperature=0.6,
top_p=0.9,
stop=["</s>", "User:", "Assistant:"],
stream=False,
)
return response["choices"][0]["message"]["content"].strip()
def _generate_hf(
self,
model,
processor,
device: str,
compute_dtype,
image: Image.Image,
prompt: str
) -> str:
"""Generate tags using HuggingFace model."""
import torch
# Resize image
image = image.resize((336, 336), Image.Resampling.LANCZOS)
convo = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
]
convo_string = processor.apply_chat_template(
convo, tokenize=False, add_generation_prompt=True
)
inputs = processor(
text=[convo_string], images=[image], return_tensors="pt"
).to(device)
# Convert pixel_values to the model's compute dtype
# (float16 for 8-bit quantized, bfloat16 for non-quantized)
if 'pixel_values' in inputs and inputs['pixel_values'] is not None:
inputs['pixel_values'] = inputs['pixel_values'].to(compute_dtype)
with torch.inference_mode(), torch.cuda.amp.autocast(enabled=True):
generate_ids = model.generate(
**inputs,
max_new_tokens=512,
do_sample=True,
temperature=0.6,
top_p=0.9,
use_cache=True,
)[0]
generate_ids = generate_ids[inputs['input_ids'].shape[1]:]
return processor.tokenizer.decode(generate_ids, skip_special_tokens=True).strip()
def _parse_tags(self, raw_output: str) -> List[str]:
"""Parse raw model output into a list of clean tags.
Args:
raw_output: Raw text output from the model
Returns:
List of cleaned, deduplicated tags
"""
# Patterns to exclude
exclude_prefixes = (
'copyright:',
'meta:',
'photo_',
'photo:',
)
# Split by common delimiters
tags = []
# Handle comma-separated tags
for part in raw_output.split(','):
tag = part.strip().lower()
# Remove any quotes or extra characters
tag = tag.strip('"\'')
# Replace spaces with underscores (Danbooru style)
tag = tag.replace(' ', '_')
# Remove empty tags
if tag and len(tag) > 1:
# Filter out unwanted tag patterns
if not tag.startswith(exclude_prefixes):
tags.append(tag)
# Deduplicate while preserving order
seen = set()
unique_tags = []
for tag in tags:
if tag not in seen:
seen.add(tag)
unique_tags.append(tag)
return unique_tags
# Singleton instance for API use
_service_instance: Optional[AutoTagService] = None
def get_autotag_service() -> AutoTagService:
"""Get or create the singleton AutoTagService instance."""
global _service_instance
if _service_instance is None:
_service_instance = AutoTagService()
return _service_instance
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.0.22"
version = "3.0.23"
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"]
+897 -14
View File
@@ -236,10 +236,14 @@
class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium rounded-lg transition-colors">
📁 Restore DB
</button>
<button id="scanBtn"
<button id="scanBtn"
class="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white text-sm font-medium rounded-lg transition-colors">
📸 Scan Images
</button>
<button id="autoTagBtn"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition-colors">
🏷️ Auto Tag
</button>
<button id="logsBtn"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors">
📋 View Logs
@@ -819,6 +823,323 @@
</div>
</div>
<!-- Auto Tag Modal -->
<div id="autoTagModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 border border-gray-700">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">🏷️ Auto Tag Images</h3>
<button onclick="hideModal('autoTagModal')" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Model Selection -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-3">Model Selection</label>
<div class="space-y-3">
<label class="flex items-center justify-between p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<div class="flex items-center">
<input type="radio" name="autoTagModel" value="gguf" checked class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">GGUF Model</span>
<span class="text-gray-400 text-sm ml-2">(Recommended, ~4.5GB)</span>
</div>
</div>
<div id="ggufModelStatus" class="flex items-center space-x-2">
<span class="text-gray-400 text-sm">Checking...</span>
</div>
</label>
<label class="flex items-center justify-between p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<div class="flex items-center">
<input type="radio" name="autoTagModel" value="hf" class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">HuggingFace Model</span>
<span class="text-gray-400 text-sm ml-2">(~16GB)</span>
</div>
</div>
<div id="hfModelStatus" class="flex items-center space-x-2">
<span class="text-gray-400 text-sm">Checking...</span>
</div>
</label>
</div>
</div>
<!-- Prompt Template -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-2">Tag Generation Prompt</label>
<textarea id="autoTagPrompt" rows="6"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-gray-100 text-sm font-mono resize-none custom-scrollbar"
placeholder="Enter prompt template...">Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).
Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.
Prioritize visual accuracy.
No sentences, no commentary, no captions. Only tags. Keep length short but descriptive.</textarea>
</div>
<!-- Tagged Images Handling (for Auto Tag mode) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-3">Already Tagged Images</label>
<div class="space-y-2">
<label class="flex items-center p-3 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<input type="radio" name="autoTagMode" value="skip" checked class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<span class="ml-3 text-gray-100">Skip images with existing tags</span>
</label>
<label class="flex items-center p-3 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<input type="radio" name="autoTagMode" value="retag" class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<span class="ml-3 text-gray-100">Re-tag all images</span>
</label>
</div>
<p class="text-gray-500 text-xs mt-2">Note: "auto-scanned" is not counted as a real tag</p>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3">
<button onclick="hideModal('autoTagModal')"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="startReviewBtn"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
👁️ Review
</button>
<button id="startAutoTagBtn"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors">
🏷️ Auto Tag
</button>
</div>
</div>
</div>
<!-- Auto Tag Review Modal -->
<div id="autoTagReviewModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-4xl w-full mx-4 border border-gray-700">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">
👁️ Review Tags
<span class="text-gray-400 text-base font-normal">(<span id="reviewCurrentIndex">1</span> of <span id="reviewTotalCount">0</span>)</span>
</h3>
<button onclick="window.admin.cancelReview()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Image Preview -->
<div class="bg-gray-900 rounded-lg p-4">
<img id="reviewImage" src="" alt="Review image" class="w-full h-64 object-contain rounded">
<p id="reviewImageName" class="text-gray-400 text-sm mt-2 text-center truncate">image_name.png</p>
</div>
<!-- Generated Tags -->
<div>
<label class="block text-sm font-medium text-gray-400 mb-3">Generated Tags (click to remove)</label>
<div id="reviewTagsContainer" class="p-4 bg-gray-900 rounded-lg">
<!-- Visible tags (first row) -->
<div id="reviewTagsVisible" class="flex flex-wrap gap-2">
<!-- First row of tags -->
</div>
<!-- Accordion for remaining tags -->
<div id="reviewTagsAccordion" class="hidden mt-3">
<div id="reviewTagsHidden" class="flex flex-wrap gap-2 max-h-[180px] overflow-y-auto custom-scrollbar">
<!-- Remaining tags -->
</div>
</div>
<!-- Toggle button -->
<button id="reviewTagsToggle" class="hidden mt-3 text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1">
<span id="reviewTagsToggleIcon">▼</span>
<span id="reviewTagsToggleText">Show all tags</span>
<span id="reviewTagsCount" class="text-gray-500"></span>
</button>
</div>
<p class="text-gray-500 text-xs mt-2">Click the × on tags to remove them before applying</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-between mt-6">
<button id="skipReviewBtn"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
⏭️ Skip
</button>
<div class="flex space-x-3">
<button onclick="window.admin.cancelReview()"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel All
</button>
<button id="applyReviewBtn"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
✅ Apply & Next
</button>
</div>
</div>
</div>
</div>
<!-- Auto Tag Progress Modal -->
<div id="autoTagProgressModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">🏷️ Auto Tagging Progress</h3>
<!-- Current File -->
<div class="mb-4">
<p class="text-gray-400 text-sm">Processing:</p>
<p id="autoTagCurrentFile" class="text-gray-100 font-medium truncate">Initializing...</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm text-gray-400 mb-2">
<span>Progress</span>
<span id="autoTagProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div id="autoTagProgressBar" class="bg-purple-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-purple-400" id="autoTagProcessed">0</div>
<div class="text-xs text-gray-400">Processed</div>
</div>
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-green-400" id="autoTagApplied">0</div>
<div class="text-xs text-gray-400">Tags Applied</div>
</div>
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-yellow-400" id="autoTagSkipped">0</div>
<div class="text-xs text-gray-400">Skipped</div>
</div>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelAutoTagBtn"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
<!-- Auto Tag Loading Model Modal -->
<div id="autoTagLoadingModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<h3 class="text-xl font-semibold text-gray-100 mb-2">Loading Model</h3>
<p id="autoTagLoadingStatus" class="text-gray-400">Initializing...</p>
<p class="text-gray-500 text-sm mt-4">This may take 30-60 seconds on first load</p>
</div>
</div>
<!-- Re-tag Confirmation Modal -->
<div id="retagConfirmModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-4">🏷️ Image Already Tagged</h3>
<!-- Image preview -->
<div class="mb-4">
<img id="retagPreviewImage" src="" alt="Preview" class="w-full h-48 object-contain bg-gray-900 rounded-lg">
<p id="retagImageName" class="text-gray-400 text-sm mt-2 text-center truncate"></p>
</div>
<!-- Existing tags -->
<div class="mb-4">
<p class="text-gray-300 text-sm mb-2">Current tags:</p>
<div id="retagExistingTags" class="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
<!-- Tags will be inserted here -->
</div>
</div>
<p class="text-gray-400 mb-6">This image already has tags. Would you like to generate new tags?</p>
<!-- Buttons -->
<div class="flex gap-3 justify-end">
<button id="retagSkipBtn"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Skip
</button>
<button id="retagSkipAllBtn"
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white font-medium rounded-lg transition-colors">
Skip All Tagged
</button>
<button id="retagConfirmBtn"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors">
Re-tag
</button>
</div>
</div>
</div>
<!-- Auto Tag Download Modal -->
<div id="autoTagDownloadModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">📥 Downloading Model</h3>
<!-- Model Name -->
<div class="mb-4">
<p class="text-gray-400 text-sm">Downloading:</p>
<p id="downloadModelName" class="text-gray-100 font-medium">GGUF Model</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm text-gray-400 mb-2">
<span id="downloadStatus">Preparing...</span>
<span id="downloadProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div id="downloadProgressBar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelDownloadBtn"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
<style>
/* Tag chip styles for Auto Tag feature */
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
background-color: #374151;
border: 1px solid #4B5563;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #D1D5DB;
transition: all 0.2s;
}
.tag-chip:hover {
background-color: #4B5563;
}
.tag-chip .tag-remove {
margin-left: 0.375rem;
padding: 0.125rem;
border-radius: 0.25rem;
cursor: pointer;
color: #9CA3AF;
transition: all 0.2s;
}
.tag-chip .tag-remove:hover {
color: #EF4444;
background-color: rgba(239, 68, 68, 0.2);
}
.hover\:bg-gray-650:hover {
background-color: #525252;
}
</style>
<script>
class PromptAdmin {
constructor() {
@@ -871,6 +1192,7 @@
document.getElementById("backupBtn").addEventListener("click", () => this.backupDatabase());
document.getElementById("restoreBtn").addEventListener("click", () => this.showRestoreModal());
document.getElementById("scanBtn").addEventListener("click", () => this.showScanModal());
document.getElementById("autoTagBtn").addEventListener("click", () => this.showAutoTagModal());
document.getElementById("logsBtn").addEventListener("click", () => this.showLogsModal());
document.getElementById("metadataBtn").addEventListener("click", () => this.openMetadataViewer());
document.getElementById("galleryBtn").addEventListener("click", () => this.openGallery());
@@ -935,6 +1257,22 @@
document.getElementById("clearLogsBtn").addEventListener("click", () => this.clearLogs());
document.getElementById("updateLogConfigBtn").addEventListener("click", () => this.updateLogConfig());
// Auto Tag modals
document.getElementById("startAutoTagBtn").addEventListener("click", () => this.startAutoTag());
document.getElementById("startReviewBtn").addEventListener("click", () => this.startReview());
document.getElementById("skipReviewBtn").addEventListener("click", () => this.skipReviewImage());
document.getElementById("applyReviewBtn").addEventListener("click", () => this.applyReviewTags());
document.getElementById("cancelAutoTagBtn").addEventListener("click", () => this.cancelAutoTag());
document.getElementById("cancelDownloadBtn").addEventListener("click", () => this.cancelDownload());
// Re-tag confirmation modal buttons
document.getElementById("retagSkipBtn").addEventListener("click", () => this.handleRetagChoice('skip'));
document.getElementById("retagSkipAllBtn").addEventListener("click", () => this.handleRetagChoice('skipAll'));
document.getElementById("retagConfirmBtn").addEventListener("click", () => this.handleRetagChoice('retag'));
// Tags accordion toggle
document.getElementById("reviewTagsToggle").addEventListener("click", () => this.toggleReviewTags());
// Close modals on backdrop click
document.querySelectorAll("[id$='Modal']").forEach((modal) => {
modal.addEventListener("click", (e) => {
@@ -1234,17 +1572,35 @@
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
${tags.map(tag => `
<span class="inline-flex items-center space-x-1 bg-blue-600/20 text-blue-300 px-3 py-1 rounded-full text-sm border border-blue-600/30">
<span>${this.escapeHtml(tag)}</span>
<button class="text-blue-300 hover:text-blue-100 ml-1" onclick="window.admin.removeTag(${prompt.id}, '${this.escapeHtml(tag)}')">&times;</button>
</span>
`).join("")}
<button class="inline-flex items-center space-x-1 bg-gray-600 hover:bg-gray-500 text-gray-300 px-3 py-1 rounded-full text-sm transition-colors" onclick="window.admin.addTag(${prompt.id})">
<span>+</span>
<span>Add Tags</span>
</button>
<div class="tags-accordion" data-prompt-id="${prompt.id}">
<div class="flex flex-wrap items-center gap-2">
${tags.slice(0, 10).map(tag => `
<span class="inline-flex items-center space-x-1 bg-blue-600/20 text-blue-300 px-3 py-1 rounded-full text-sm border border-blue-600/30">
<span>${this.escapeHtml(tag)}</span>
<button class="text-blue-300 hover:text-blue-100 ml-1" onclick="window.admin.removeTag(${prompt.id}, '${this.escapeHtml(tag)}')">&times;</button>
</span>
`).join("")}
<button class="inline-flex items-center space-x-1 bg-gray-600 hover:bg-gray-500 text-gray-300 px-3 py-1 rounded-full text-sm transition-colors" onclick="window.admin.addTag(${prompt.id})">
<span>+</span>
<span>Add Tags</span>
</button>
</div>
${tags.length > 10 ? `
<div class="tags-hidden hidden mt-2">
<div class="flex flex-wrap items-center gap-2 max-h-[180px] overflow-y-auto custom-scrollbar p-2 bg-gray-800/50 rounded-lg">
${tags.slice(10).map(tag => `
<span class="inline-flex items-center space-x-1 bg-blue-600/20 text-blue-300 px-3 py-1 rounded-full text-sm border border-blue-600/30">
<span>${this.escapeHtml(tag)}</span>
<button class="text-blue-300 hover:text-blue-100 ml-1" onclick="window.admin.removeTag(${prompt.id}, '${this.escapeHtml(tag)}')">&times;</button>
</span>
`).join("")}
</div>
</div>
<button class="tags-toggle-btn mt-2 text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1" onclick="window.admin.toggleMainTags(${prompt.id})">
<span class="toggle-icon">▼</span>
<span class="toggle-text">Show ${tags.length - 10} more tags</span>
</button>
` : ''}
</div>
</div>
@@ -3892,7 +4248,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
copyAllMetadata() {
if (!this.currentMetadata) return;
const allData = `Checkpoint: ${this.currentMetadata.checkpoint || 'Unknown'}
Positive Prompt: ${this.currentMetadata.positivePrompt}
Negative Prompt: ${this.currentMetadata.negativePrompt}
@@ -3900,9 +4256,536 @@ Steps: ${this.currentMetadata.steps || 'Unknown'}
CFG Scale: ${this.currentMetadata.cfgScale || 'Unknown'}
Sampler: ${this.currentMetadata.sampler || 'Unknown'}
Seed: ${this.currentMetadata.seed || 'Unknown'}`;
this.copyToClipboard(allData);
}
// ==========================================
// Auto Tag Feature Methods
// ==========================================
// State for AutoTag feature
autoTagState = {
eventSource: null,
downloadEventSource: null,
reviewImages: [],
reviewIndex: 0,
currentTags: [],
cancelled: false,
skipAllTagged: false,
retagResolve: null, // Promise resolver for retag confirmation
tagsExpanded: false // Track accordion state
};
async showAutoTagModal() {
this.showModal("autoTagModal");
await this.checkAutoTagModels();
}
async checkAutoTagModels() {
const ggufStatus = document.getElementById("ggufModelStatus");
const hfStatus = document.getElementById("hfModelStatus");
ggufStatus.innerHTML = '<span class="text-gray-400 text-sm">Checking...</span>';
hfStatus.innerHTML = '<span class="text-gray-400 text-sm">Checking...</span>';
try {
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success) {
// Update GGUF status
if (data.models.gguf.downloaded) {
ggufStatus.innerHTML = '<span class="text-green-400 text-sm">✓ Downloaded</span>';
} else {
ggufStatus.innerHTML = `<button onclick="window.admin.downloadModel('gguf')" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">Download</button>`;
}
// Update HF status
if (data.models.hf.downloaded) {
hfStatus.innerHTML = '<span class="text-green-400 text-sm">✓ Downloaded</span>';
} else {
hfStatus.innerHTML = `<button onclick="window.admin.downloadModel('hf')" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">Download</button>`;
}
} else {
ggufStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
hfStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
}
} catch (error) {
console.error('Error checking models:', error);
ggufStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
hfStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
}
}
async downloadModel(modelType) {
const modelName = modelType === 'gguf' ? 'GGUF Model' : 'HuggingFace Model';
document.getElementById('downloadModelName').textContent = modelName;
document.getElementById('downloadStatus').textContent = 'Preparing...';
document.getElementById('downloadProgressPercent').textContent = '0%';
document.getElementById('downloadProgressBar').style.width = '0%';
this.hideModal("autoTagModal");
this.showModal("autoTagDownloadModal");
try {
this.autoTagState.downloadEventSource = new EventSource(`/prompt_manager/autotag/download/${modelType}`);
this.autoTagState.downloadEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'downloading') {
const percent = Math.round(data.progress * 100);
document.getElementById('downloadStatus').textContent = data.message || 'Downloading...';
document.getElementById('downloadProgressPercent').textContent = `${percent}%`;
document.getElementById('downloadProgressBar').style.width = `${percent}%`;
} else if (data.status === 'complete') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.checkAutoTagModels();
this.showNotification('Model downloaded successfully!', 'success');
} else if (data.status === 'error') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification(`Download failed: ${data.message}`, 'error');
}
};
this.autoTagState.downloadEventSource.onerror = () => {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Download connection lost', 'error');
};
} catch (error) {
console.error('Download error:', error);
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Failed to start download', 'error');
}
}
cancelDownload() {
if (this.autoTagState.downloadEventSource) {
this.autoTagState.downloadEventSource.close();
this.autoTagState.downloadEventSource = null;
}
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
}
async startAutoTag() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
const prompt = document.getElementById('autoTagPrompt').value;
const skipTagged = document.querySelector('input[name="autoTagMode"]:checked').value === 'skip';
// Check if model is downloaded
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
this.showNotification('Please download the selected model first', 'warning');
return;
}
this.hideModal("autoTagModal");
this.showModal("autoTagProgressModal");
// Reset progress display
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
document.getElementById('autoTagProgressPercent').textContent = '0%';
document.getElementById('autoTagProgressBar').style.width = '0%';
document.getElementById('autoTagProcessed').textContent = '0';
document.getElementById('autoTagApplied').textContent = '0';
document.getElementById('autoTagSkipped').textContent = '0';
this.autoTagState.cancelled = false;
try {
const formData = new URLSearchParams();
formData.append('model_type', modelType);
formData.append('prompt', prompt);
formData.append('skip_tagged', skipTagged ? 'true' : 'false');
this.autoTagState.eventSource = new EventSource(`/prompt_manager/autotag/start?${formData.toString()}`);
this.autoTagState.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
document.getElementById('autoTagCurrentFile').textContent = data.status || 'Processing...';
document.getElementById('autoTagProgressPercent').textContent = `${data.progress || 0}%`;
document.getElementById('autoTagProgressBar').style.width = `${data.progress || 0}%`;
if (data.processed !== undefined) {
document.getElementById('autoTagProcessed').textContent = data.processed;
document.getElementById('autoTagApplied').textContent = data.tagged || 0;
document.getElementById('autoTagSkipped').textContent = data.skipped || 0;
}
} else if (data.type === 'complete') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tagging complete! Applied tags to ${data.tagged || 0} prompts.`, 'success');
this.search(); // Refresh the prompt list
} else if (data.type === 'error') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tag error: ${data.message}`, 'error');
} else if (data.type === 'cancelled') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification('Auto tagging cancelled', 'info');
}
};
this.autoTagState.eventSource.onerror = (error) => {
console.error('AutoTag SSE error:', error);
console.error('EventSource readyState:', this.autoTagState.eventSource.readyState);
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification('Connection lost during auto tagging', 'error');
};
} catch (error) {
console.error('Auto tag error:', error);
this.hideModal("autoTagProgressModal");
this.showNotification('Failed to start auto tagging', 'error');
}
}
cancelAutoTag() {
this.autoTagState.cancelled = true;
if (this.autoTagState.eventSource) {
this.autoTagState.eventSource.close();
this.autoTagState.eventSource = null;
}
this.hideModal("autoTagProgressModal");
this.showNotification('Auto tagging cancelled', 'info');
}
async startReview() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
// Check if model is downloaded
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
this.showNotification('Please download the selected model first', 'warning');
return;
}
this.hideModal("autoTagModal");
// Show loading modal while we fetch images and load model
this.showModal("autoTagLoadingModal");
document.getElementById('autoTagLoadingStatus').textContent = 'Fetching images from database...';
// Get ALL images with linked prompts from the database
try {
const scanResponse = await fetch('/prompt_manager/images/all');
const scanData = await scanResponse.json();
if (!scanData.success || !scanData.images || scanData.images.length === 0) {
this.hideModal("autoTagLoadingModal");
this.showNotification('No images with linked prompts found in database', 'warning');
return;
}
this.autoTagState.reviewImages = scanData.images;
this.autoTagState.reviewIndex = 0;
this.autoTagState.modelType = modelType;
this.autoTagState.prompt = document.getElementById('autoTagPrompt').value;
this.autoTagState.modelLoaded = false;
this.autoTagState.skipAllTagged = false; // Reset skip flag for new session
document.getElementById('reviewTotalCount').textContent = scanData.images.length;
// Update loading status - model will load on first image
document.getElementById('autoTagLoadingStatus').textContent =
`Found ${scanData.images.length} images. Loading ${modelType.toUpperCase()} model...`;
// Load first image (this will load the model)
await this.loadNextReviewImage();
// Hide loading, show review modal
this.hideModal("autoTagLoadingModal");
this.showModal("autoTagReviewModal");
} catch (error) {
console.error('Error starting review:', error);
this.hideModal("autoTagLoadingModal");
this.showNotification('Failed to start review mode', 'error');
}
}
// Check if tags array has "real" tags (excluding auto-scanned)
getRealTags(tags) {
if (!tags || !Array.isArray(tags)) return [];
return tags.filter(tag => tag !== 'auto-scanned');
}
// Show the re-tag confirmation modal
async showRetagConfirmation(image, imageUrl, realTags) {
return new Promise((resolve) => {
this.autoTagState.retagResolve = resolve;
// Set up the modal content
document.getElementById('retagPreviewImage').src = imageUrl;
document.getElementById('retagImageName').textContent = image.image_path.split('/').pop();
// Display existing tags
const tagsContainer = document.getElementById('retagExistingTags');
tagsContainer.innerHTML = realTags.map(tag =>
`<span class="px-2 py-1 bg-purple-600 text-white text-xs rounded">${this.escapeHtml(tag)}</span>`
).join('');
// Show modal
this.showModal('retagConfirmModal');
});
}
// Handle user's choice in re-tag modal
handleRetagChoice(choice) {
this.hideModal('retagConfirmModal');
if (choice === 'skipAll') {
this.autoTagState.skipAllTagged = true;
}
if (this.autoTagState.retagResolve) {
this.autoTagState.retagResolve(choice);
this.autoTagState.retagResolve = null;
}
}
async loadNextReviewImage() {
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
this.hideModal("autoTagReviewModal");
this.showNotification('Review complete!', 'success');
this.search();
return;
}
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
document.getElementById('reviewCurrentIndex').textContent = this.autoTagState.reviewIndex + 1;
// Build image URL from image_path (database field)
const imagePath = image.image_path;
const filename = imagePath.split('/').pop();
// Use the serve endpoint with relative path
const relPath = imagePath.includes('/output/') ?
imagePath.substring(imagePath.indexOf('/output/') + 8) : filename;
const imageUrl = `/prompt_manager/images/serve/${relPath}`;
// Check if image already has real tags (excluding auto-scanned)
const realTags = this.getRealTags(image.prompt_tags);
if (realTags.length > 0) {
// Skip if "Skip All Tagged" was selected
if (this.autoTagState.skipAllTagged) {
this.autoTagState.reviewIndex++;
return this.loadNextReviewImage();
}
// Show confirmation modal
const choice = await this.showRetagConfirmation(image, imageUrl, realTags);
if (choice === 'skip' || choice === 'skipAll') {
this.autoTagState.reviewIndex++;
return this.loadNextReviewImage();
}
// choice === 'retag' - continue with tagging
}
document.getElementById('reviewImage').src = imageUrl;
document.getElementById('reviewImageName').textContent = filename || 'Unknown';
document.getElementById('reviewTagsVisible').innerHTML = '<div class="text-gray-400">Generating tags...</div>';
document.getElementById('reviewTagsHidden').innerHTML = '';
document.getElementById('reviewTagsAccordion').classList.add('hidden');
document.getElementById('reviewTagsToggle').classList.add('hidden');
// Store prompt_id from database for applying tags later
this.autoTagState.currentPromptId = image.prompt_id;
this.autoTagState.tagsExpanded = false; // Reset accordion for new image
try {
const response = await fetch('/prompt_manager/autotag/single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_path: image.image_path,
model_type: this.autoTagState.modelType,
prompt: this.autoTagState.prompt
})
});
const data = await response.json();
if (data.success) {
this.autoTagState.currentTags = data.tags;
// Use prompt_id from database, fallback to API response
this.autoTagState.currentPromptId = image.prompt_id || data.prompt_id;
this.renderReviewTags();
} else {
document.getElementById('reviewTagsVisible').innerHTML =
`<div class="text-red-400">Error: ${data.error}</div>`;
}
} catch (error) {
console.error('Error generating tags:', error);
document.getElementById('reviewTagsVisible').innerHTML =
'<div class="text-red-400">Failed to generate tags</div>';
}
}
renderReviewTags() {
const visibleContainer = document.getElementById('reviewTagsVisible');
const hiddenContainer = document.getElementById('reviewTagsHidden');
const accordion = document.getElementById('reviewTagsAccordion');
const toggleBtn = document.getElementById('reviewTagsToggle');
const toggleIcon = document.getElementById('reviewTagsToggleIcon');
const toggleText = document.getElementById('reviewTagsToggleText');
const countSpan = document.getElementById('reviewTagsCount');
if (this.autoTagState.currentTags.length === 0) {
visibleContainer.innerHTML = '<div class="text-gray-400">No tags generated</div>';
hiddenContainer.innerHTML = '';
accordion.classList.add('hidden');
toggleBtn.classList.add('hidden');
return;
}
// Show first ~10 tags in visible row, rest in accordion
const visibleCount = Math.min(10, this.autoTagState.currentTags.length);
const visibleTags = this.autoTagState.currentTags.slice(0, visibleCount);
const hiddenTags = this.autoTagState.currentTags.slice(visibleCount);
const createTagChip = (tag, index) => `
<span class="tag-chip">
${this.escapeHtml(tag)}
<span class="tag-remove" onclick="window.admin.removeReviewTag(${index})">×</span>
</span>
`;
visibleContainer.innerHTML = visibleTags.map((tag, i) => createTagChip(tag, i)).join('');
if (hiddenTags.length > 0) {
hiddenContainer.innerHTML = hiddenTags.map((tag, i) => createTagChip(tag, visibleCount + i)).join('');
toggleBtn.classList.remove('hidden');
countSpan.textContent = `(${hiddenTags.length} more)`;
// Reset accordion state
if (!this.autoTagState.tagsExpanded) {
accordion.classList.add('hidden');
toggleIcon.textContent = '▼';
toggleText.textContent = 'Show all tags';
}
} else {
hiddenContainer.innerHTML = '';
accordion.classList.add('hidden');
toggleBtn.classList.add('hidden');
}
}
toggleReviewTags() {
const accordion = document.getElementById('reviewTagsAccordion');
const toggleIcon = document.getElementById('reviewTagsToggleIcon');
const toggleText = document.getElementById('reviewTagsToggleText');
this.autoTagState.tagsExpanded = !this.autoTagState.tagsExpanded;
if (this.autoTagState.tagsExpanded) {
accordion.classList.remove('hidden');
toggleIcon.textContent = '▲';
toggleText.textContent = 'Show less';
} else {
accordion.classList.add('hidden');
toggleIcon.textContent = '▼';
toggleText.textContent = 'Show all tags';
}
}
toggleMainTags(promptId) {
const container = document.querySelector(`.tags-accordion[data-prompt-id="${promptId}"]`);
if (!container) return;
const hiddenSection = container.querySelector('.tags-hidden');
const toggleBtn = container.querySelector('.tags-toggle-btn');
if (!hiddenSection || !toggleBtn) return;
const toggleIcon = toggleBtn.querySelector('.toggle-icon');
const toggleText = toggleBtn.querySelector('.toggle-text');
const isHidden = hiddenSection.classList.contains('hidden');
if (isHidden) {
hiddenSection.classList.remove('hidden');
toggleIcon.textContent = '▲';
toggleText.textContent = 'Show less';
} else {
hiddenSection.classList.add('hidden');
toggleIcon.textContent = '▼';
// Recalculate the count from the hidden tags
const hiddenTags = hiddenSection.querySelectorAll('span.inline-flex').length;
toggleText.textContent = `Show ${hiddenTags} more tags`;
}
}
removeReviewTag(index) {
this.autoTagState.currentTags.splice(index, 1);
this.renderReviewTags();
}
skipReviewImage() {
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
async applyReviewTags() {
if (this.autoTagState.currentTags.length === 0) {
this.skipReviewImage();
return;
}
const promptId = this.autoTagState.currentPromptId;
if (!promptId) {
this.showNotification('No linked prompt found for this image', 'warning');
this.skipReviewImage();
return;
}
try {
const response = await fetch('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt_id: promptId,
tags: this.autoTagState.currentTags
})
});
const data = await response.json();
if (data.success) {
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
} else {
this.showNotification(data.error || 'Failed to apply tags', 'warning');
}
} catch (error) {
console.error('Error applying tags:', error);
this.showNotification('Failed to apply tags', 'error');
}
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
cancelReview() {
this.autoTagState.reviewImages = [];
this.autoTagState.reviewIndex = 0;
this.autoTagState.currentTags = [];
this.autoTagState.skipAllTagged = false;
this.hideModal("autoTagReviewModal");
}
}
// Initialize the admin interface
+645 -4
View File
@@ -183,10 +183,14 @@
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors">
☰ List View
</button>
<button id="settingsBtn"
<button id="settingsBtn"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors">
⚙️ Settings
</button>
<button id="autoTagBtn"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition-colors">
🏷️ Auto Tag
</button>
</div>
</div>
</div>
@@ -553,6 +557,243 @@
</div>
</div>
<!-- Auto Tag Modal -->
<div id="autoTagModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 border border-gray-700">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">🏷️ Auto Tag Images</h3>
<button onclick="hideModal('autoTagModal')" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Model Selection -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-3">Model Selection</label>
<div class="space-y-3">
<label class="flex items-center justify-between p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<div class="flex items-center">
<input type="radio" name="autoTagModel" value="gguf" checked class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">GGUF Model</span>
<span class="text-gray-400 text-sm ml-2">(Recommended, ~4.5GB)</span>
</div>
</div>
<div id="ggufModelStatus" class="flex items-center space-x-2">
<span class="text-gray-400 text-sm">Checking...</span>
</div>
</label>
<label class="flex items-center justify-between p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<div class="flex items-center">
<input type="radio" name="autoTagModel" value="hf" class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">HuggingFace Model</span>
<span class="text-gray-400 text-sm ml-2">(~16GB)</span>
</div>
</div>
<div id="hfModelStatus" class="flex items-center space-x-2">
<span class="text-gray-400 text-sm">Checking...</span>
</div>
</label>
</div>
</div>
<!-- Prompt Template -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-2">Tag Generation Prompt</label>
<textarea id="autoTagPrompt" rows="6"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-gray-100 text-sm font-mono resize-none custom-scrollbar"
placeholder="Enter prompt template...">Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).
Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.
Prioritize visual accuracy.
No sentences, no commentary, no captions. Only tags. Keep length short but descriptive.</textarea>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3">
<button onclick="hideModal('autoTagModal')"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="startReviewBtn"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
👁️ Review
</button>
<button id="startAutoTagBtn"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors">
🏷️ Auto Tag
</button>
</div>
</div>
</div>
<!-- Auto Tag Review Modal -->
<div id="autoTagReviewModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-4xl w-full mx-4 border border-gray-700">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">
👁️ Review Tags
<span class="text-gray-400 text-base font-normal">(<span id="reviewCurrentIndex">1</span> of <span id="reviewTotalCount">0</span>)</span>
</h3>
<button onclick="window.gallery.cancelReview()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Image Preview -->
<div class="bg-gray-900 rounded-lg p-4">
<img id="reviewImage" src="" alt="Review image" class="w-full h-64 object-contain rounded">
<p id="reviewImageName" class="text-gray-400 text-sm mt-2 text-center truncate">image_name.png</p>
</div>
<!-- Generated Tags -->
<div>
<label class="block text-sm font-medium text-gray-400 mb-3">Generated Tags (click to remove)</label>
<div id="reviewTagsContainer" class="flex flex-wrap gap-2 p-4 bg-gray-900 rounded-lg min-h-[200px] max-h-[250px] overflow-y-auto custom-scrollbar">
<!-- Tag chips will be inserted here -->
</div>
<p class="text-gray-500 text-xs mt-2">Click the × on tags to remove them before applying</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-between mt-6">
<button id="skipReviewBtn"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
⏭️ Skip
</button>
<div class="flex space-x-3">
<button onclick="window.gallery.cancelReview()"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel All
</button>
<button id="applyReviewBtn"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
✅ Apply & Next
</button>
</div>
</div>
</div>
</div>
<!-- Auto Tag Progress Modal -->
<div id="autoTagProgressModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">🏷️ Auto Tagging Progress</h3>
<!-- Current File -->
<div class="mb-4">
<p class="text-gray-400 text-sm">Processing:</p>
<p id="autoTagCurrentFile" class="text-gray-100 font-medium truncate">Initializing...</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm text-gray-400 mb-2">
<span>Progress</span>
<span id="autoTagProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div id="autoTagProgressBar" class="bg-purple-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-purple-400" id="autoTagProcessed">0</div>
<div class="text-xs text-gray-400">Processed</div>
</div>
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-green-400" id="autoTagApplied">0</div>
<div class="text-xs text-gray-400">Tags Applied</div>
</div>
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-yellow-400" id="autoTagSkipped">0</div>
<div class="text-xs text-gray-400">Skipped</div>
</div>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelAutoTagBtn"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
<!-- Auto Tag Download Modal -->
<div id="autoTagDownloadModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">📥 Downloading Model</h3>
<!-- Model Name -->
<div class="mb-4">
<p class="text-gray-400 text-sm">Downloading:</p>
<p id="downloadModelName" class="text-gray-100 font-medium">GGUF Model</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm text-gray-400 mb-2">
<span id="downloadStatus">Preparing...</span>
<span id="downloadProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div id="downloadProgressBar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelDownloadBtn"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
<style>
/* Tag chip styles for Auto Tag feature */
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
background-color: #374151;
border: 1px solid #4B5563;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #D1D5DB;
transition: all 0.2s;
}
.tag-chip:hover {
background-color: #4B5563;
}
.tag-chip .tag-remove {
margin-left: 0.375rem;
padding: 0.125rem;
border-radius: 0.25rem;
cursor: pointer;
color: #9CA3AF;
transition: all 0.2s;
}
.tag-chip .tag-remove:hover {
color: #EF4444;
background-color: rgba(239, 68, 68, 0.2);
}
.hover\:bg-gray-650:hover {
background-color: #525252;
}
</style>
<script>
class GalleryManager {
constructor() {
@@ -576,6 +817,7 @@
document.getElementById('gridViewBtn').addEventListener('click', () => this.setViewMode('grid'));
document.getElementById('listViewBtn').addEventListener('click', () => this.setViewMode('list'));
document.getElementById('settingsBtn').addEventListener('click', () => this.showSettings());
document.getElementById('autoTagBtn').addEventListener('click', () => this.showAutoTagModal());
document.getElementById('retryBtn').addEventListener('click', () => this.loadImages());
document.getElementById('prevPageBtn').addEventListener('click', () => this.previousPage());
document.getElementById('nextPageBtn').addEventListener('click', () => this.nextPage());
@@ -612,6 +854,23 @@
this.hideDuplicatesModal();
}
});
// Auto Tag modal event listeners
document.getElementById('startAutoTagBtn').addEventListener('click', () => this.startAutoTag());
document.getElementById('startReviewBtn').addEventListener('click', () => this.startReview());
document.getElementById('skipReviewBtn').addEventListener('click', () => this.skipReviewImage());
document.getElementById('applyReviewBtn').addEventListener('click', () => this.applyReviewTags());
document.getElementById('cancelAutoTagBtn').addEventListener('click', () => this.cancelAutoTag());
document.getElementById('cancelDownloadBtn').addEventListener('click', () => this.cancelDownload());
// Close AutoTag modals on outside click
['autoTagModal', 'autoTagReviewModal', 'autoTagProgressModal', 'autoTagDownloadModal'].forEach(modalId => {
document.getElementById(modalId).addEventListener('click', (e) => {
if (e.target.id === modalId) {
this.hideModal(modalId);
}
});
});
}
async loadImages() {
@@ -3273,22 +3532,404 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
addStatusMessage('Connection to server lost', 'error');
progressBar.classList.add('bg-red-600');
progressBar.classList.remove('bg-violet-600');
// Clean up
eventSource.close();
if (!resultData) {
reject(new Error('Connection to server lost during thumbnail generation'));
}
};
});
}
// ==========================================
// Auto Tag Feature Methods
// ==========================================
// State for AutoTag feature
autoTagState = {
eventSource: null,
downloadEventSource: null,
reviewImages: [],
reviewIndex: 0,
currentTags: [],
cancelled: false
};
showModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
}
hideModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
document.body.style.overflow = '';
}
async showAutoTagModal() {
this.showModal("autoTagModal");
await this.checkAutoTagModels();
}
async checkAutoTagModels() {
const ggufStatus = document.getElementById("ggufModelStatus");
const hfStatus = document.getElementById("hfModelStatus");
ggufStatus.innerHTML = '<span class="text-gray-400 text-sm">Checking...</span>';
hfStatus.innerHTML = '<span class="text-gray-400 text-sm">Checking...</span>';
try {
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success) {
// Update GGUF status
if (data.models.gguf.downloaded) {
ggufStatus.innerHTML = '<span class="text-green-400 text-sm">✓ Downloaded</span>';
} else {
ggufStatus.innerHTML = `<button onclick="window.gallery.downloadModel('gguf')" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">Download</button>`;
}
// Update HF status
if (data.models.hf.downloaded) {
hfStatus.innerHTML = '<span class="text-green-400 text-sm">✓ Downloaded</span>';
} else {
hfStatus.innerHTML = `<button onclick="window.gallery.downloadModel('hf')" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">Download</button>`;
}
} else {
ggufStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
hfStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
}
} catch (error) {
console.error('Error checking models:', error);
ggufStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
hfStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
}
}
async downloadModel(modelType) {
const modelName = modelType === 'gguf' ? 'GGUF Model' : 'HuggingFace Model';
document.getElementById('downloadModelName').textContent = modelName;
document.getElementById('downloadStatus').textContent = 'Preparing...';
document.getElementById('downloadProgressPercent').textContent = '0%';
document.getElementById('downloadProgressBar').style.width = '0%';
this.hideModal("autoTagModal");
this.showModal("autoTagDownloadModal");
try {
this.autoTagState.downloadEventSource = new EventSource(`/prompt_manager/autotag/download/${modelType}`);
this.autoTagState.downloadEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'downloading') {
const percent = Math.round(data.progress * 100);
document.getElementById('downloadStatus').textContent = data.message || 'Downloading...';
document.getElementById('downloadProgressPercent').textContent = `${percent}%`;
document.getElementById('downloadProgressBar').style.width = `${percent}%`;
} else if (data.status === 'complete') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.checkAutoTagModels();
this.showNotification('Model downloaded successfully!', 'success');
} else if (data.status === 'error') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification(`Download failed: ${data.message}`, 'error');
}
};
this.autoTagState.downloadEventSource.onerror = () => {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Download connection lost', 'error');
};
} catch (error) {
console.error('Download error:', error);
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Failed to start download', 'error');
}
}
cancelDownload() {
if (this.autoTagState.downloadEventSource) {
this.autoTagState.downloadEventSource.close();
this.autoTagState.downloadEventSource = null;
}
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
}
async startAutoTag() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
const prompt = document.getElementById('autoTagPrompt').value;
// Check if model is downloaded
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
this.showNotification('Please download the selected model first', 'warning');
return;
}
this.hideModal("autoTagModal");
this.showModal("autoTagProgressModal");
// Reset progress display
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
document.getElementById('autoTagProgressPercent').textContent = '0%';
document.getElementById('autoTagProgressBar').style.width = '0%';
document.getElementById('autoTagProcessed').textContent = '0';
document.getElementById('autoTagApplied').textContent = '0';
document.getElementById('autoTagSkipped').textContent = '0';
this.autoTagState.cancelled = false;
try {
const formData = new URLSearchParams();
formData.append('model_type', modelType);
formData.append('prompt', prompt);
this.autoTagState.eventSource = new EventSource(`/prompt_manager/autotag/start?${formData.toString()}`);
this.autoTagState.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'loading_model') {
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
} else if (data.status === 'processing') {
const percent = Math.round((data.processed / data.total) * 100);
document.getElementById('autoTagCurrentFile').textContent = data.current_file || 'Processing...';
document.getElementById('autoTagProgressPercent').textContent = `${percent}%`;
document.getElementById('autoTagProgressBar').style.width = `${percent}%`;
document.getElementById('autoTagProcessed').textContent = data.processed;
document.getElementById('autoTagApplied').textContent = data.tags_applied;
document.getElementById('autoTagSkipped').textContent = data.skipped;
} else if (data.status === 'complete') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tagging complete! Applied tags to ${data.tags_applied} prompts.`, 'success');
this.loadImages(); // Refresh the gallery
} else if (data.status === 'error') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tag error: ${data.message}`, 'error');
} else if (data.status === 'cancelled') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification('Auto tagging cancelled', 'info');
}
};
this.autoTagState.eventSource.onerror = () => {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification('Connection lost during auto tagging', 'error');
};
} catch (error) {
console.error('Auto tag error:', error);
this.hideModal("autoTagProgressModal");
this.showNotification('Failed to start auto tagging', 'error');
}
}
cancelAutoTag() {
this.autoTagState.cancelled = true;
if (this.autoTagState.eventSource) {
this.autoTagState.eventSource.close();
this.autoTagState.eventSource = null;
}
this.hideModal("autoTagProgressModal");
this.showNotification('Auto tagging cancelled', 'info');
}
async startReview() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
// Check if model is downloaded
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
this.showNotification('Please download the selected model first', 'warning');
return;
}
this.hideModal("autoTagModal");
// Use current gallery images for review
if (!this.images || this.images.length === 0) {
this.showNotification('No images found in gallery', 'warning');
return;
}
this.autoTagState.reviewImages = this.images;
this.autoTagState.reviewIndex = 0;
this.autoTagState.modelType = modelType;
this.autoTagState.prompt = document.getElementById('autoTagPrompt').value;
document.getElementById('reviewTotalCount').textContent = this.images.length;
this.showModal("autoTagReviewModal");
await this.loadNextReviewImage();
}
async loadNextReviewImage() {
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
this.hideModal("autoTagReviewModal");
this.showNotification('Review complete!', 'success');
this.loadImages();
return;
}
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
document.getElementById('reviewCurrentIndex').textContent = this.autoTagState.reviewIndex + 1;
document.getElementById('reviewImage').src = image.thumbnail_url || image.url;
document.getElementById('reviewImageName').textContent = image.filename || 'Unknown';
document.getElementById('reviewTagsContainer').innerHTML = '<div class="text-gray-400">Generating tags...</div>';
try {
const response = await fetch('/prompt_manager/autotag/single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_path: image.path,
model_type: this.autoTagState.modelType,
prompt: this.autoTagState.prompt
})
});
const data = await response.json();
if (data.success) {
this.autoTagState.currentTags = data.tags;
this.renderReviewTags();
} else {
document.getElementById('reviewTagsContainer').innerHTML =
`<div class="text-red-400">Error: ${data.error}</div>`;
}
} catch (error) {
console.error('Error generating tags:', error);
document.getElementById('reviewTagsContainer').innerHTML =
'<div class="text-red-400">Failed to generate tags</div>';
}
}
renderReviewTags() {
const container = document.getElementById('reviewTagsContainer');
if (this.autoTagState.currentTags.length === 0) {
container.innerHTML = '<div class="text-gray-400">No tags generated</div>';
return;
}
container.innerHTML = this.autoTagState.currentTags.map((tag, index) => `
<span class="tag-chip">
${this.escapeHtml(tag)}
<span class="tag-remove" onclick="window.gallery.removeReviewTag(${index})">×</span>
</span>
`).join('');
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
removeReviewTag(index) {
this.autoTagState.currentTags.splice(index, 1);
this.renderReviewTags();
}
skipReviewImage() {
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
async applyReviewTags() {
if (this.autoTagState.currentTags.length === 0) {
this.skipReviewImage();
return;
}
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
try {
const response = await fetch('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_path: image.path,
tags: this.autoTagState.currentTags
})
});
const data = await response.json();
if (data.success) {
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
} else {
this.showNotification(data.error || 'Failed to apply tags', 'warning');
}
} catch (error) {
console.error('Error applying tags:', error);
this.showNotification('Failed to apply tags', 'error');
}
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
cancelReview() {
this.autoTagState.reviewImages = [];
this.autoTagState.reviewIndex = 0;
this.autoTagState.currentTags = [];
this.hideModal("autoTagReviewModal");
}
showNotification(message, type = 'info') {
// Simple notification - could be enhanced with a toast library
const colors = {
success: 'bg-green-600',
error: 'bg-red-600',
warning: 'bg-yellow-600',
info: 'bg-blue-600'
};
const notification = document.createElement('div');
notification.className = `fixed bottom-4 right-4 ${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg z-50 animate-fade-in`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
}
// Initialize gallery when page loads
document.addEventListener('DOMContentLoaded', () => {
new GalleryManager();
window.gallery = new GalleryManager();
});
// Global helper for modal close buttons
function hideModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
document.body.style.overflow = '';
}
</script>
</body>
</html>