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

@@ -10,7 +10,7 @@ from werkzeug.utils import secure_filename
from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
from services.file_io import get_available_loras
from services.job_queue import _enqueue_job, _make_finalize
from services.llm import call_llm, load_prompt
from services.llm import call_character_mcp_tool, call_llm, load_prompt
from services.prompts import build_prompt
from services.sync import sync_characters
from services.workflow import _get_default_checkpoint, _prepare_workflow
@@ -23,8 +23,17 @@ def register_routes(app):
@app.route('/')
def index():
characters = Character.query.order_by(Character.name).all()
return render_template('index.html', characters=characters)
query = Character.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)
characters = query.order_by(Character.is_favourite.desc(), Character.name).all()
return render_template('index.html', characters=characters, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/rescan', methods=['POST'])
def rescan():
@@ -219,6 +228,7 @@ def register_routes(app):
name = request.form.get('name')
slug = request.form.get('filename', '').strip()
prompt = request.form.get('prompt', '')
wiki_url = request.form.get('wiki_url', '').strip()
use_llm = request.form.get('use_llm') == 'on'
outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none'
existing_outfit_id = request.form.get('existing_outfit_id')
@@ -228,6 +238,7 @@ def register_routes(app):
'name': name,
'filename': slug,
'prompt': prompt,
'wiki_url': wiki_url,
'use_llm': use_llm,
'outfit_mode': outfit_mode,
'existing_outfit_id': existing_outfit_id
@@ -261,6 +272,20 @@ def register_routes(app):
flash(error_msg)
return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all())
# Fetch reference data from wiki URL if provided
wiki_reference = ''
if wiki_url:
logger.info(f"Fetching character data from URL: {wiki_url}")
wiki_data = call_character_mcp_tool('get_character_from_url', {
'url': wiki_url,
'name': name,
})
if wiki_data:
wiki_reference = f"\n\nReference data from wiki:\n{wiki_data}\n\nUse this reference to accurately describe the character's appearance, outfit, and features."
logger.info(f"Got wiki reference data ({len(wiki_data)} chars)")
else:
logger.warning(f"Failed to fetch wiki data from {wiki_url}")
# Step 1: Generate or select outfit first
default_outfit_id = 'default'
generated_outfit = None
@@ -271,7 +296,7 @@ def register_routes(app):
outfit_name = f"{name} - default"
outfit_prompt = f"""Generate an outfit for character "{name}".
The character is described as: {prompt}
The character is described as: {prompt}{wiki_reference}
Create an outfit JSON with wardrobe fields appropriate for this character."""
@@ -344,7 +369,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
# Step 2: Generate character (without wardrobe section)
char_prompt = f"""Generate a character named "{name}".
Description: {prompt}
Description: {prompt}{wiki_reference}
Default Outfit: {default_outfit_id}
@@ -516,9 +541,13 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
if form_key in request.form:
new_data['wardrobe'][key] = request.form.get(form_key)
# 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]
# Update structured tags
new_data['tags'] = {
'origin_series': request.form.get('tag_origin_series', '').strip(),
'origin_type': request.form.get('tag_origin_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
character.is_nsfw = new_data['tags']['nsfw']
character.data = new_data
flag_modified(character, "data")
@@ -867,3 +896,12 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
db.session.commit()
flash('Default prompt selection saved for this character!')
return redirect(url_for('detail', slug=slug))
@app.route('/character/<path:slug>/favourite', methods=['POST'])
def toggle_character_favourite(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
character.is_favourite = not character.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': character.is_favourite}
return redirect(url_for('detail', slug=slug))