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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user