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

@@ -1,7 +1,6 @@
import json
import os
import re
import time
import random
import logging
@@ -11,7 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Action, Outfit, Style, Scene, Detailer, Checkpoint, Settings, Look
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_actions
from services.file_io import get_available_loras
@@ -38,8 +37,17 @@ def register_routes(app):
@app.route('/actions')
def actions_index():
actions = Action.query.order_by(Action.name).all()
return render_template('actions/index.html', actions=actions)
query = Action.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)
actions = query.order_by(Action.is_favourite.desc(), Action.name).all()
return render_template('actions/index.html', actions=actions, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/actions/rescan', methods=['POST'])
def rescan_actions():
@@ -118,9 +126,15 @@ def register_routes(app):
else:
new_data.setdefault('lora', {}).pop(bound, None)
# Update Tags (comma separated string to list)
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for f in tags_raw.split(',') for t in [f.strip()] if t]
# Suppress wardrobe toggle
new_data['suppress_wardrobe'] = request.form.get('suppress_wardrobe') == 'on'
# Update Tags (structured dict)
new_data['tags'] = {
'participants': request.form.get('tag_participants', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
action.is_nsfw = new_data['tags']['nsfw']
action.data = new_data
flag_modified(action, "data")
@@ -201,6 +215,12 @@ def register_routes(app):
session[f'extra_neg_action_{slug}'] = extra_negative
session.modified = True
suppress_wardrobe = action_obj.data.get('suppress_wardrobe', False)
# Strip any wardrobe fields from manual selection when suppressed
if suppress_wardrobe:
selected_fields = [f for f in selected_fields if not f.startswith('wardrobe::')]
# Build combined data for prompt building
if character:
# Combine character identity/wardrobe with action details
@@ -232,16 +252,13 @@ def register_routes(app):
if 'lora' not in combined_data: combined_data['lora'] = {}
combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}"
# Merge tags
combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', [])))
# Use action's defaults if no manual selection
if not selected_fields:
selected_fields = list(action_obj.default_fields) if action_obj.default_fields else []
# Auto-include essential character fields if a character is selected
if selected_fields:
_ensure_character_fields(character, selected_fields)
_ensure_character_fields(character, selected_fields, include_wardrobe=not suppress_wardrobe)
else:
# Fallback to sensible defaults if still empty (no checkboxes and no action defaults)
selected_fields = ['special::name', 'defaults::pose', 'defaults::expression']
@@ -249,12 +266,13 @@ def register_routes(app):
for key in ['base', 'head']:
if character.data.get('identity', {}).get(key):
selected_fields.append(f'identity::{key}')
# Add wardrobe fields
from utils import _WARDROBE_KEYS
wardrobe = character.get_active_wardrobe()
for key in _WARDROBE_KEYS:
if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}')
# Add wardrobe fields (unless suppressed)
if not suppress_wardrobe:
from utils import _WARDROBE_KEYS
wardrobe = character.get_active_wardrobe()
for key in _WARDROBE_KEYS:
if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}')
default_fields = action_obj.default_fields
active_outfit = character.active_outfit
@@ -281,7 +299,7 @@ def register_routes(app):
'tags': action_obj.data.get('tags', [])
}
if not selected_fields:
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags']
selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers']
default_fields = action_obj.default_fields
active_outfit = 'default'
@@ -322,13 +340,14 @@ def register_routes(app):
val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ')
extra_parts.append(val)
# Wardrobe (active outfit)
from utils import _WARDROBE_KEYS
wardrobe = extra_char.get_active_wardrobe()
for key in _WARDROBE_KEYS:
val = wardrobe.get(key)
if val:
extra_parts.append(val)
# Wardrobe (active outfit) — skip if suppressed
if not suppress_wardrobe:
from utils import _WARDROBE_KEYS
wardrobe = extra_char.get_active_wardrobe()
for key in _WARDROBE_KEYS:
val = wardrobe.get(key)
if val:
extra_parts.append(val)
# Append to main prompt
if extra_parts:
@@ -391,72 +410,76 @@ def register_routes(app):
actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/')
_lora_subfolder = os.path.basename(actions_lora_dir)
if not os.path.exists(actions_lora_dir):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Actions LoRA directory not found.'}, 400
flash('Actions LoRA directory not found.', 'error')
return redirect(url_for('actions_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('action_system.txt')
if not system_prompt:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Action system prompt file not found.'}, 500
flash('Action system prompt file not found.', 'error')
return redirect(url_for('actions_index'))
for filename in os.listdir(actions_lora_dir):
if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0]
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
job_ids = []
skipped = 0
json_filename = f"{action_id}.json"
json_path = os.path.join(app.config['ACTIONS_DIR'], json_filename)
for filename in sorted(os.listdir(actions_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]
action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower())
action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
html_filename = f"{name_base}.html"
html_path = os.path.join(actions_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()
# Strip HTML tags but keep text content for LLM context
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"{action_id}.json"
json_path = os.path.join(app.config['ACTIONS_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(actions_lora_dir, f"{name_base}.html")
html_content = ""
if os.path.exists(html_path):
try:
print(f"Asking LLM to describe action: {action_name}")
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA 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, aid, aname, jp, lsf, html_ctx, sys_prompt, is_exist):
def task_fn(job):
prompt = f"Describe an action/pose for an AI image generation model based on the LoRA 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###"
# Clean response
llm_response = call_llm(prompt, sys_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
action_data = json.loads(clean_json)
# Enforce system values while preserving LLM-extracted metadata
action_data['action_id'] = action_id
action_data['action_name'] = action_name
action_data['action_id'] = aid
action_data['action_name'] = aname
# Update lora dict safely
if 'lora' not in action_data: action_data['lora'] = {}
action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if 'lora' not in action_data:
action_data['lora'] = {}
action_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
# Fallbacks if LLM failed to extract metadata
if not action_data['lora'].get('lora_triggers'):
action_data['lora']['lora_triggers'] = name_base
action_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
if action_data['lora'].get('lora_weight') is None:
action_data['lora']['lora_weight'] = 1.0
if action_data['lora'].get('lora_weight_min') is None:
@@ -464,39 +487,45 @@ def register_routes(app):
if action_data['lora'].get('lora_weight_max') is None:
action_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(action_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': aname, 'action': 'overwritten' if is_exist else 'created'}
return task_fn
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
job = _enqueue_task(
f"Create action: {action_name}",
make_task(filename, action_id, action_name, json_path,
_lora_subfolder, html_content, system_prompt, is_existing)
)
job_ids.append(job['id'])
except Exception as e:
print(f"Error creating action for {filename}: {e}")
# Enqueue a sync task to run after all creates
if job_ids:
def sync_task(job):
sync_actions()
job['result'] = {'synced': True}
_enqueue_task("Sync actions DB", sync_task)
if created_count > 0 or overwritten_count > 0:
sync_actions()
msg = f'Successfully processed actions: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No actions created or overwritten. {skipped_count} existing actions found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} action creation tasks ({skipped} skipped). Watch progress in the queue.')
return redirect(url_for('actions_index'))
@app.route('/action/create', methods=['GET', 'POST'])
def create_action():
form_data = {}
if request.method == 'POST':
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
prompt = request.form.get('prompt', '')
use_llm = request.form.get('use_llm') == 'on'
form_data = {'name': name, 'filename': slug, 'prompt': prompt, 'use_llm': use_llm}
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -513,12 +542,12 @@ def register_routes(app):
if use_llm:
if not prompt:
flash("Description is required when AI generation is enabled.")
return redirect(request.url)
return render_template('actions/create.html', form_data=form_data)
system_prompt = load_prompt('action_system.txt')
if not system_prompt:
flash("Action system prompt file not found.")
return redirect(request.url)
return render_template('actions/create.html', form_data=form_data)
try:
llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt)
@@ -529,7 +558,7 @@ def register_routes(app):
except Exception as e:
print(f"LLM error: {e}")
flash(f"Failed to generate action profile: {e}")
return redirect(request.url)
return render_template('actions/create.html', form_data=form_data)
else:
action_data = {
"action_id": safe_slug,
@@ -538,6 +567,7 @@ def register_routes(app):
"base": "", "head": "", "upper_body": "", "lower_body": "",
"hands": "", "feet": "", "additional": ""
},
"suppress_wardrobe": False,
"lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""},
"tags": []
}
@@ -559,9 +589,9 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create action: {e}")
return redirect(request.url)
return render_template('actions/create.html', form_data=form_data)
return render_template('actions/create.html')
return render_template('actions/create.html', form_data=form_data)
@app.route('/action/<path:slug>/clone', methods=['POST'])
def clone_action(slug):
@@ -619,3 +649,12 @@ def register_routes(app):
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/action/<path:slug>/favourite', methods=['POST'])
def toggle_action_favourite(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
action.is_favourite = not action.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': action.is_favourite}
return redirect(url_for('action_detail', slug=slug))