Add semantic tagging, search, favourite/NSFW filtering, and LLM job queue

Replaces old list-format tags (which duplicated prompt content) with structured
dict tags per category (origin_series, outfit_type, participants, style_type,
scene_type, etc.). Tags are now purely organizational metadata — removed from
the prompt pipeline entirely.

Adds is_favourite and is_nsfw columns to all 8 resource models. Favourite is
DB-only (user preference); NSFW is mirrored in JSON tags for rescan persistence.
All library pages get filter controls and favourites-first sorting.

Introduces a parallel LLM job queue (_enqueue_task + _llm_queue_worker) for
background tag regeneration, with the same status polling UI as ComfyUI jobs.
Fixes call_llm() to use has_request_context() fallback for background threads.

Adds global search (/search) across resources and gallery images, with navbar
search bar. Adds gallery image sidecar JSON for per-image favourite/NSFW metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-21 03:22:09 +00:00
parent 7d79e626a5
commit 32a73b02f5
72 changed files with 3163 additions and 2212 deletions

View File

@@ -2,7 +2,6 @@ import json
import os
import re
import random
import time
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Style, Detailer, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
from services.sync import sync_styles
from services.file_io import get_available_loras
@@ -82,8 +81,17 @@ def register_routes(app):
@app.route('/styles')
def styles_index():
styles = Style.query.order_by(Style.name).all()
return render_template('styles/index.html', styles=styles)
query = Style.query
fav = request.args.get('favourite')
nsfw = request.args.get('nsfw', 'all')
if fav == 'on':
query = query.filter_by(is_favourite=True)
if nsfw == 'sfw':
query = query.filter_by(is_nsfw=False)
elif nsfw == 'nsfw':
query = query.filter_by(is_nsfw=True)
styles = query.order_by(Style.is_favourite.desc(), Style.name).all()
return render_template('styles/index.html', styles=styles, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/styles/rescan', methods=['POST'])
def rescan_styles():
@@ -158,6 +166,13 @@ def register_routes(app):
else:
new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (structured dict)
new_data['tags'] = {
'style_type': request.form.get('tag_style_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
style.is_nsfw = new_data['tags']['nsfw']
style.data = new_data
flag_modified(style, "data")
@@ -343,66 +358,73 @@ def register_routes(app):
styles_lora_dir = ((_s.lora_dir_styles if _s else None) or '/ImageModels/lora/Illustrious/Styles').rstrip('/')
_lora_subfolder = os.path.basename(styles_lora_dir)
if not os.path.exists(styles_lora_dir):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Styles LoRA directory not found.'}, 400
flash('Styles LoRA directory not found.', 'error')
return redirect(url_for('styles_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('style_system.txt')
if not system_prompt:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Style system prompt file not found.'}, 500
flash('Style system prompt file not found.', 'error')
return redirect(url_for('styles_index'))
for filename in os.listdir(styles_lora_dir):
if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0]
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
job_ids = []
skipped = 0
json_filename = f"{style_id}.json"
json_path = os.path.join(app.config['STYLES_DIR'], json_filename)
for filename in sorted(os.listdir(styles_lora_dir)):
if not filename.endswith('.safetensors'):
continue
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
continue
name_base = filename.rsplit('.', 1)[0]
style_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
style_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
html_filename = f"{name_base}.html"
html_path = os.path.join(styles_lora_dir, html_filename)
html_content = ""
if os.path.exists(html_path):
try:
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
html_raw = hf.read()
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML {html_filename}: {e}")
json_filename = f"{style_id}.json"
json_path = os.path.join(app.config['STYLES_DIR'], json_filename)
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped += 1
continue
# Read HTML companion file if it exists
html_path = os.path.join(styles_lora_dir, f"{name_base}.html")
html_content = ""
if os.path.exists(html_path):
try:
print(f"Asking LLM to describe style: {style_name}")
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_content[:3000]}\n###"
with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf:
html_raw = hf.read()
clean_html = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL)
clean_html = re.sub(r'<style[^>]*>.*?</style>', '', clean_html, flags=re.DOTALL)
clean_html = re.sub(r'<img[^>]*>', '', clean_html)
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception:
pass
llm_response = call_llm(prompt, system_prompt)
def make_task(fn, sid, sname, jp, lsf, html_ctx, sys_prompt, is_exist):
def task_fn(job):
prompt = f"Describe an art style or artist LoRA for AI image generation based on the filename: '{fn}'"
if html_ctx:
prompt += f"\n\nHere is descriptive text and metadata extracted from an associated HTML file for this LoRA:\n###\n{html_ctx[:3000]}\n###"
llm_response = call_llm(prompt, sys_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
style_data = json.loads(clean_json)
style_data['style_id'] = style_id
style_data['style_name'] = style_name
style_data['style_id'] = sid
style_data['style_name'] = sname
if 'lora' not in style_data: style_data['lora'] = {}
style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if 'lora' not in style_data:
style_data['lora'] = {}
style_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
if not style_data['lora'].get('lora_triggers'):
style_data['lora']['lora_triggers'] = name_base
style_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
if style_data['lora'].get('lora_weight') is None:
style_data['lora']['lora_weight'] = 1.0
if style_data['lora'].get('lora_weight_min') is None:
@@ -410,35 +432,43 @@ def register_routes(app):
if style_data['lora'].get('lora_weight_max') is None:
style_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f:
os.makedirs(os.path.dirname(jp), exist_ok=True)
with open(jp, 'w') as f:
json.dump(style_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': sname, 'action': 'overwritten' if is_exist else 'created'}
return task_fn
time.sleep(0.5)
except Exception as e:
print(f"Error creating style for {filename}: {e}")
job = _enqueue_task(
f"Create style: {style_name}",
make_task(filename, style_id, style_name, json_path,
_lora_subfolder, html_content, system_prompt, is_existing)
)
job_ids.append(job['id'])
if created_count > 0 or overwritten_count > 0:
sync_styles()
msg = f'Successfully processed styles: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No styles created or overwritten. {skipped_count} existing styles found.')
# Enqueue a sync task to run after all creates
if job_ids:
def sync_task(job):
sync_styles()
job['result'] = {'synced': True}
_enqueue_task("Sync styles DB", sync_task)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} style creation tasks ({skipped} skipped). Watch progress in the queue.')
return redirect(url_for('styles_index'))
@app.route('/style/create', methods=['GET', 'POST'])
def create_style():
form_data = {}
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
form_data = {'name': name, 'filename': slug}
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -483,9 +513,9 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create style: {e}")
return redirect(request.url)
return render_template('styles/create.html', form_data=form_data)
return render_template('styles/create.html')
return render_template('styles/create.html', form_data=form_data)
@app.route('/style/<path:slug>/clone', methods=['POST'])
def clone_style(slug):
@@ -542,3 +572,12 @@ def register_routes(app):
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/style/<path:slug>/favourite', methods=['POST'])
def toggle_style_favourite(slug):
style_obj = Style.query.filter_by(slug=slug).first_or_404()
style_obj.is_favourite = not style_obj.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': style_obj.is_favourite}
return redirect(url_for('style_detail', slug=slug))