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

@@ -18,6 +18,8 @@ def register_routes(app):
from routes import strengths
from routes import transfer
from routes import api
from routes import regenerate
from routes import search
queue_api.register_routes(app)
settings.register_routes(app)
@@ -37,3 +39,5 @@ def register_routes(app):
strengths.register_routes(app)
transfer.register_routes(app)
api.register_routes(app)
regenerate.register_routes(app)
search.register_routes(app)

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))

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))

View File

@@ -1,7 +1,6 @@
import json
import os
import re
import time
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Checkpoint, Character, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts
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_checkpoints, _default_checkpoint_data
from services.file_io import get_available_checkpoints
@@ -57,8 +56,17 @@ def register_routes(app):
@app.route('/checkpoints')
def checkpoints_index():
checkpoints = Checkpoint.query.order_by(Checkpoint.name).all()
return render_template('checkpoints/index.html', checkpoints=checkpoints)
query = Checkpoint.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)
checkpoints = query.order_by(Checkpoint.is_favourite.desc(), Checkpoint.name).all()
return render_template('checkpoints/index.html', checkpoints=checkpoints, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/checkpoints/rescan', methods=['POST'])
def rescan_checkpoints():
@@ -189,9 +197,9 @@ def register_routes(app):
os.makedirs(checkpoints_dir, exist_ok=True)
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
skipped = 0
written_directly = 0
job_ids = []
system_prompt = load_prompt('checkpoint_system.txt')
if not system_prompt:
@@ -219,7 +227,7 @@ def register_routes(app):
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
# Look for a matching HTML file alongside the model file
@@ -235,52 +243,72 @@ def register_routes(app):
clean_html = re.sub(r'<[^>]+>', ' ', clean_html)
html_content = ' '.join(clean_html.split())
except Exception as e:
print(f"Error reading HTML for {filename}: {e}")
logger.error("Error reading HTML for %s: %s", filename, e)
defaults = _default_checkpoint_data(checkpoint_path, filename)
if html_content:
try:
print(f"Asking LLM to describe checkpoint: {filename}")
prompt = (
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
f"(checkpoint_path: '{checkpoint_path}').\n\n"
f"Here is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
)
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
ckpt_data = json.loads(clean_json)
# Enforce fixed fields
ckpt_data['checkpoint_path'] = checkpoint_path
ckpt_data['checkpoint_name'] = filename
# Fill missing fields with defaults
for key, val in defaults.items():
if key not in ckpt_data or ckpt_data[key] is None:
ckpt_data[key] = val
time.sleep(0.5)
except Exception as e:
print(f"LLM error for {filename}: {e}. Using defaults.")
ckpt_data = defaults
# Has HTML companion — enqueue LLM task
def make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing):
def task_fn(job):
prompt = (
f"Generate checkpoint metadata JSON for the model file: '{filename}' "
f"(checkpoint_path: '{checkpoint_path}').\n\n"
f"Here is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
)
try:
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
ckpt_data = json.loads(clean_json)
ckpt_data['checkpoint_path'] = checkpoint_path
ckpt_data['checkpoint_name'] = filename
for key, val in defaults.items():
if key not in ckpt_data or ckpt_data[key] is None:
ckpt_data[key] = val
except Exception as e:
logger.error("LLM error for %s: %s. Using defaults.", filename, e)
ckpt_data = defaults
with open(json_path, 'w') as f:
json.dump(ckpt_data, f, indent=2)
job['result'] = {'name': filename, 'action': 'overwritten' if is_existing else 'created'}
return task_fn
job = _enqueue_task(f"Create checkpoint: {filename}", make_task(filename, checkpoint_path, json_path, html_content, system_prompt, defaults, is_existing))
job_ids.append(job['id'])
else:
ckpt_data = defaults
# No HTML — write defaults directly (no LLM needed)
try:
with open(json_path, 'w') as f:
json.dump(defaults, f, indent=2)
written_directly += 1
except Exception as e:
logger.error("Error saving JSON for %s: %s", filename, e)
try:
with open(json_path, 'w') as f:
json.dump(ckpt_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
except Exception as e:
print(f"Error saving JSON for {filename}: {e}")
needs_sync = len(job_ids) > 0 or written_directly > 0
if created_count > 0 or overwritten_count > 0:
sync_checkpoints()
msg = f'Successfully processed checkpoints: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No checkpoints created or overwritten. {skipped_count} existing entries found.')
if needs_sync:
if job_ids:
# Sync after all LLM tasks complete
def sync_task(job):
sync_checkpoints()
job['result'] = {'synced': True}
_enqueue_task("Sync checkpoints DB", sync_task)
else:
# No LLM tasks — sync immediately
sync_checkpoints()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'written_directly': written_directly, 'skipped': skipped}
flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).')
return redirect(url_for('checkpoints_index'))
@app.route('/checkpoint/<path:slug>/favourite', methods=['POST'])
def toggle_checkpoint_favourite(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
ckpt.is_favourite = not ckpt.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': ckpt.is_favourite}
return redirect(url_for('checkpoint_detail', slug=slug))

View File

@@ -1,7 +1,6 @@
import json
import os
import re
import time
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Detailer, Action, Outfit, Style, Scene, 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_detailers
from services.file_io import get_available_loras
@@ -27,11 +26,8 @@ def register_routes(app):
combined_data = character.data.copy()
combined_data['character_id'] = character.character_id
# Merge detailer prompt into character's tags
# Capture detailer prompt for injection into main prompt later
detailer_prompt = detailer_obj.data.get('prompt', '')
if detailer_prompt:
if 'tags' not in combined_data: combined_data['tags'] = []
combined_data['tags'].append(detailer_prompt)
# Merge detailer lora triggers if present
detailer_lora = detailer_obj.data.get('lora', {})
@@ -53,21 +49,19 @@ def register_routes(app):
for key in _WARDROBE_KEYS:
if wardrobe.get(key):
selected_fields.append(f'wardrobe::{key}')
selected_fields.extend(['special::tags', 'lora::lora_triggers'])
selected_fields.extend(['lora::lora_triggers'])
default_fields = detailer_obj.default_fields
active_outfit = character.active_outfit
else:
# Detailer only - no character
detailer_prompt = detailer_obj.data.get('prompt', '')
detailer_tags = [detailer_prompt] if detailer_prompt else []
combined_data = {
'character_id': detailer_obj.detailer_id,
'tags': detailer_tags,
'lora': detailer_obj.data.get('lora', {}),
}
if not selected_fields:
selected_fields = ['special::tags', 'lora::lora_triggers']
selected_fields = ['lora::lora_triggers']
default_fields = detailer_obj.default_fields
active_outfit = 'default'
@@ -76,6 +70,11 @@ def register_routes(app):
prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit)
# Inject detailer prompt directly into main prompt
if detailer_prompt:
prompt_str = detailer_prompt if isinstance(detailer_prompt, str) else ', '.join(detailer_prompt)
prompts['main'] = f"{prompts['main']}, {prompt_str}" if prompts['main'] else prompt_str
_append_background(prompts, character)
if extra_positive:
@@ -87,8 +86,17 @@ def register_routes(app):
@app.route('/detailers')
def detailers_index():
detailers = Detailer.query.order_by(Detailer.name).all()
return render_template('detailers/index.html', detailers=detailers)
query = Detailer.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)
detailers = query.order_by(Detailer.is_favourite.desc(), Detailer.name).all()
return render_template('detailers/index.html', detailers=detailers, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/detailers/rescan', methods=['POST'])
def rescan_detailers():
@@ -162,9 +170,13 @@ 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 t in tags_raw.split(',') if t.strip()]
# Update structured tags
new_data['tags'] = {
'associated_resource': request.form.get('tag_associated_resource', '').strip(),
'adetailer_targets': request.form.getlist('tag_adetailer_targets'),
'nsfw': 'tag_nsfw' in request.form,
}
detailer.is_nsfw = new_data['tags']['nsfw']
detailer.data = new_data
flag_modified(detailer, "data")
@@ -318,15 +330,16 @@ def register_routes(app):
return redirect(url_for('detailers_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
skipped = 0
job_ids = []
system_prompt = load_prompt('detailer_system.txt')
if not system_prompt:
flash('Detailer system prompt file not found.', 'error')
return redirect(url_for('detailers_index'))
detailers_dir = app.config['DETAILERS_DIR']
for filename in os.listdir(detailers_lora_dir):
if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0]
@@ -334,11 +347,11 @@ def register_routes(app):
detailer_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{detailer_id}.json"
json_path = os.path.join(app.config['DETAILERS_DIR'], json_filename)
json_path = os.path.join(detailers_dir, json_filename)
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
@@ -354,63 +367,63 @@ def register_routes(app):
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}")
logger.error("Error reading HTML %s: %s", html_filename, e)
try:
print(f"Asking LLM to describe detailer: {detailer_name}")
prompt = f"Describe a detailer 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###"
def make_task(filename, name_base, detailer_id, detailer_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
def task_fn(job):
prompt = f"Describe a detailer 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###"
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
detailer_data = json.loads(clean_json)
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
detailer_data = json.loads(clean_json)
detailer_data['detailer_id'] = detailer_id
detailer_data['detailer_name'] = detailer_name
detailer_data['detailer_id'] = detailer_id
detailer_data['detailer_name'] = detailer_name
if 'lora' not in detailer_data: detailer_data['lora'] = {}
detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if 'lora' not in detailer_data: detailer_data['lora'] = {}
detailer_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not detailer_data['lora'].get('lora_triggers'):
detailer_data['lora']['lora_triggers'] = name_base
if detailer_data['lora'].get('lora_weight') is None:
detailer_data['lora']['lora_weight'] = 1.0
if detailer_data['lora'].get('lora_weight_min') is None:
detailer_data['lora']['lora_weight_min'] = 0.7
if detailer_data['lora'].get('lora_weight_max') is None:
detailer_data['lora']['lora_weight_max'] = 1.0
if not detailer_data['lora'].get('lora_triggers'):
detailer_data['lora']['lora_triggers'] = name_base
if detailer_data['lora'].get('lora_weight') is None:
detailer_data['lora']['lora_weight'] = 1.0
if detailer_data['lora'].get('lora_weight_min') is None:
detailer_data['lora']['lora_weight_min'] = 0.7
if detailer_data['lora'].get('lora_weight_max') is None:
detailer_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f:
json.dump(detailer_data, f, indent=2)
with open(json_path, 'w') as f:
json.dump(detailer_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': detailer_name, 'action': 'overwritten' if is_existing else 'created'}
return task_fn
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
except Exception as e:
print(f"Error creating detailer for {filename}: {e}")
job = _enqueue_task(f"Create detailer: {detailer_name}", make_task(filename, name_base, detailer_id, detailer_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_detailers()
msg = f'Successfully processed detailers: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No new detailers created or overwritten. {skipped_count} existing detailers found.')
if job_ids:
def sync_task(job):
sync_detailers()
job['result'] = {'synced': True}
_enqueue_task("Sync detailers 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)} detailer tasks ({skipped} skipped).')
return redirect(url_for('detailers_index'))
@app.route('/detailer/create', methods=['GET', 'POST'])
def create_detailer():
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('_')
@@ -452,6 +465,15 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create detailer: {e}")
return redirect(request.url)
return render_template('detailers/create.html', form_data=form_data)
return render_template('detailers/create.html')
return render_template('detailers/create.html', form_data=form_data)
@app.route('/detailer/<path:slug>/favourite', methods=['POST'])
def toggle_detailer_favourite(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
detailer.is_favourite = not detailer.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': detailer.is_favourite}
return redirect(url_for('detailer_detail', slug=slug))

View File

@@ -4,13 +4,13 @@ import logging
from flask import render_template, request, current_app
from models import (
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint,
db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint, Preset,
)
logger = logging.getLogger('gaze')
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints']
GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints', 'looks', 'presets', 'generator']
_MODEL_MAP = {
'characters': Character,
@@ -20,11 +20,36 @@ _MODEL_MAP = {
'styles': Style,
'detailers': Detailer,
'checkpoints': Checkpoint,
'looks': Look,
'presets': Preset,
'generator': Preset,
}
# Maps xref_category param names to sidecar JSON keys
_XREF_KEY_MAP = {
'character': 'character_slug',
'outfit': 'outfit_slug',
'action': 'action_slug',
'style': 'style_slug',
'scene': 'scene_slug',
'detailer': 'detailer_slug',
'look': 'look_slug',
'preset': 'preset_slug',
}
def register_routes(app):
def _read_sidecar(upload_folder, image_path):
"""Read JSON sidecar for an image. Returns dict or None."""
sidecar = image_path.rsplit('.', 1)[0] + '.json'
sidecar_path = os.path.join(upload_folder, sidecar)
try:
with open(sidecar_path) as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def _scan_gallery_images(category_filter='all', slug_filter=''):
"""Return sorted list of image dicts from the uploads directory."""
upload_folder = app.config['UPLOAD_FOLDER']
@@ -164,18 +189,48 @@ def register_routes(app):
category = request.args.get('category', 'all')
slug = request.args.get('slug', '')
sort = request.args.get('sort', 'newest')
xref_category = request.args.get('xref_category', '')
xref_slug = request.args.get('xref_slug', '')
favourite_filter = request.args.get('favourite', '')
nsfw_filter = request.args.get('nsfw', 'all')
page = max(1, int(request.args.get('page', 1)))
per_page = int(request.args.get('per_page', 48))
per_page = per_page if per_page in (24, 48, 96) else 48
images = _scan_gallery_images(category, slug)
# Read sidecar data for filtering (favourite/NSFW/xref)
upload_folder = app.config['UPLOAD_FOLDER']
need_sidecar = (xref_category and xref_slug) or favourite_filter or nsfw_filter != 'all'
if need_sidecar:
for img in images:
img['_sidecar'] = _read_sidecar(upload_folder, img['path']) or {}
# Cross-reference filter
if xref_category and xref_slug and xref_category in _XREF_KEY_MAP:
sidecar_key = _XREF_KEY_MAP[xref_category]
images = [img for img in images if img.get('_sidecar', {}).get(sidecar_key) == xref_slug]
# Favourite filter
if favourite_filter == 'on':
images = [img for img in images if img.get('_sidecar', {}).get('is_favourite')]
# NSFW filter
if nsfw_filter == 'sfw':
images = [img for img in images if not img.get('_sidecar', {}).get('is_nsfw')]
elif nsfw_filter == 'nsfw':
images = [img for img in images if img.get('_sidecar', {}).get('is_nsfw')]
if sort == 'oldest':
images.reverse()
elif sort == 'random':
import random
random.shuffle(images)
# Sort favourites first when favourite filter not active but sort is newest/oldest
if sort in ('newest', 'oldest') and not favourite_filter and need_sidecar:
images.sort(key=lambda x: (not x.get('_sidecar', {}).get('is_favourite', False), images.index(x)))
total = len(images)
total_pages = max(1, (total + per_page - 1) // per_page)
page = min(page, total_pages)
@@ -197,6 +252,11 @@ def register_routes(app):
if Model:
slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()]
# Attach sidecar data to page images for template use
for img in page_images:
if '_sidecar' not in img:
img['_sidecar'] = _read_sidecar(os.path.abspath(app.config['UPLOAD_FOLDER']), img['path']) or {}
return render_template(
'gallery.html',
images=page_images,
@@ -209,6 +269,10 @@ def register_routes(app):
sort=sort,
categories=GALLERY_CATEGORIES,
slug_options=slug_options,
xref_category=xref_category,
xref_slug=xref_slug,
favourite_filter=favourite_filter,
nsfw_filter=nsfw_filter,
)
@app.route('/gallery/prompt-data')
@@ -228,8 +292,60 @@ def register_routes(app):
meta = _parse_comfy_png_metadata(abs_img)
meta['path'] = img_path
# Include sidecar data if available (for cross-reference links)
sidecar = _read_sidecar(upload_folder, img_path)
if sidecar:
meta['sidecar'] = sidecar
return meta
def _write_sidecar(upload_folder, image_path, data):
"""Write/update JSON sidecar for an image."""
sidecar = image_path.rsplit('.', 1)[0] + '.json'
sidecar_path = os.path.join(upload_folder, sidecar)
existing = {}
try:
with open(sidecar_path) as f:
existing = json.load(f)
except (OSError, json.JSONDecodeError):
pass
existing.update(data)
with open(sidecar_path, 'w') as f:
json.dump(existing, f, indent=2)
@app.route('/gallery/image/favourite', methods=['POST'])
def gallery_image_favourite():
"""Toggle favourite on a gallery image via sidecar JSON."""
data = request.get_json(silent=True) or {}
img_path = data.get('path', '')
if not img_path:
return {'error': 'path required'}, 400
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
if not abs_img.startswith(upload_folder + os.sep) or not os.path.isfile(abs_img):
return {'error': 'Invalid path'}, 400
sidecar = _read_sidecar(upload_folder, img_path) or {}
new_val = not sidecar.get('is_favourite', False)
_write_sidecar(upload_folder, img_path, {'is_favourite': new_val})
return {'success': True, 'is_favourite': new_val}
@app.route('/gallery/image/nsfw', methods=['POST'])
def gallery_image_nsfw():
"""Toggle NSFW on a gallery image via sidecar JSON."""
data = request.get_json(silent=True) or {}
img_path = data.get('path', '')
if not img_path:
return {'error': 'path required'}, 400
upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER'])
abs_img = os.path.abspath(os.path.join(upload_folder, img_path))
if not abs_img.startswith(upload_folder + os.sep) or not os.path.isfile(abs_img):
return {'error': 'Invalid path'}, 400
sidecar = _read_sidecar(upload_folder, img_path) or {}
new_val = not sidecar.get('is_nsfw', False)
_write_sidecar(upload_folder, img_path, {'is_nsfw': new_val})
return {'success': True, 'is_nsfw': new_val}
@app.route('/gallery/delete', methods=['POST'])
def gallery_delete():
"""Delete a generated image from the gallery. Only the image file is removed."""
@@ -249,6 +365,10 @@ def register_routes(app):
if os.path.isfile(abs_img):
os.remove(abs_img)
# Also remove sidecar JSON if present
sidecar = abs_img.rsplit('.', 1)[0] + '.json'
if os.path.isfile(sidecar):
os.remove(sidecar)
return {'status': 'ok'}
@@ -260,6 +380,7 @@ def register_routes(app):
hard: removes JSON data file + LoRA/checkpoint safetensors + DB record.
"""
_RESOURCE_MODEL_MAP = {
'characters': Character,
'looks': Look,
'styles': Style,
'actions': Action,
@@ -269,6 +390,7 @@ def register_routes(app):
'checkpoints': Checkpoint,
}
_RESOURCE_DATA_DIRS = {
'characters': app.config['CHARACTERS_DIR'],
'looks': app.config['LOOKS_DIR'],
'styles': app.config['STYLES_DIR'],
'actions': app.config['ACTIONS_DIR'],

View File

@@ -1,154 +1,135 @@
import json
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Checkpoint
from services.prompts import build_prompt, build_extras_prompt
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize
from flask import render_template, request, redirect, url_for, flash
from models import Preset
from services.generation import generate_from_preset
from services.file_io import get_available_checkpoints
from services.comfyui import get_loaded_checkpoint
from services.workflow import _get_default_checkpoint
from services.sync import _resolve_preset_entity
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/generator', methods=['GET', 'POST'])
@app.route('/generator', methods=['GET'])
def generator():
characters = Character.query.order_by(Character.name).all()
presets = Preset.query.order_by(Preset.name).all()
checkpoints = get_available_checkpoints()
actions = Action.query.order_by(Action.name).all()
outfits = Outfit.query.order_by(Outfit.name).all()
scenes = Scene.query.order_by(Scene.name).all()
styles = Style.query.order_by(Style.name).all()
detailers = Detailer.query.order_by(Detailer.name).all()
if not checkpoints:
checkpoints = ["Noob/oneObsession_v19Atypical.safetensors"]
# Default to whatever is currently loaded in ComfyUI, then settings default
selected_ckpt = get_loaded_checkpoint()
if not selected_ckpt:
default_path, _ = _get_default_checkpoint()
selected_ckpt = default_path
if request.method == 'POST':
char_slug = request.form.get('character')
checkpoint = request.form.get('checkpoint')
custom_positive = request.form.get('positive_prompt', '')
custom_negative = request.form.get('negative_prompt', '')
# Pre-select preset from query param
preset_slug = request.args.get('preset', '')
action_slugs = request.form.getlist('action_slugs')
outfit_slugs = request.form.getlist('outfit_slugs')
scene_slugs = request.form.getlist('scene_slugs')
style_slugs = request.form.getlist('style_slugs')
detailer_slugs = request.form.getlist('detailer_slugs')
override_prompt = request.form.get('override_prompt', '').strip()
width = request.form.get('width') or 1024
height = request.form.get('height') or 1024
return render_template('generator.html',
presets=presets,
checkpoints=checkpoints,
selected_ckpt=selected_ckpt,
preset_slug=preset_slug)
character = Character.query.filter_by(slug=char_slug).first_or_404()
@app.route('/generator/generate', methods=['POST'])
def generator_generate():
preset_slug = request.form.get('preset_slug', '').strip()
if not preset_slug:
return {'error': 'No preset selected'}, 400
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
preset = Preset.query.filter_by(slug=preset_slug).first()
if not preset:
return {'error': 'Preset not found'}, 404
try:
with open('comfy_workflow.json', 'r') as f:
workflow = json.load(f)
try:
overrides = {
'checkpoint': request.form.get('checkpoint', '').strip() or None,
'extra_positive': request.form.get('extra_positive', '').strip(),
'extra_negative': request.form.get('extra_negative', '').strip(),
'action': 'preview',
}
# Build base prompts from character defaults
prompts = build_prompt(character.data, default_fields=character.default_fields)
seed_val = request.form.get('seed', '').strip()
if seed_val:
overrides['seed'] = int(seed_val)
if override_prompt:
prompts["main"] = override_prompt
else:
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
combined = prompts["main"]
if extras:
combined = f"{combined}, {extras}"
if custom_positive:
combined = f"{custom_positive}, {combined}"
prompts["main"] = combined
width = request.form.get('width', '').strip()
height = request.form.get('height', '').strip()
if width and height:
overrides['width'] = int(width)
overrides['height'] = int(height)
# Apply face/hand prompt overrides if provided
override_face = request.form.get('override_face_prompt', '').strip()
override_hand = request.form.get('override_hand_prompt', '').strip()
if override_face:
prompts["face"] = override_face
if override_hand:
prompts["hand"] = override_hand
job = generate_from_preset(preset, overrides, save_category='generator')
# Parse optional seed
seed_val = request.form.get('seed', '').strip()
fixed_seed = int(seed_val) if seed_val else None
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
# Prepare workflow - first selected item per category supplies its LoRA slot
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint).first() if checkpoint else None
workflow = _prepare_workflow(
workflow, character, prompts, checkpoint, custom_negative,
outfit=sel_outfits[0] if sel_outfits else None,
action=sel_actions[0] if sel_actions else None,
style=sel_styles[0] if sel_styles else None,
detailer=sel_detailers[0] if sel_detailers else None,
scene=sel_scenes[0] if sel_scenes else None,
width=width,
height=height,
checkpoint_data=ckpt_obj.data if ckpt_obj else None,
fixed_seed=fixed_seed,
)
flash("Generation queued.")
return redirect(url_for('generator', preset=preset_slug))
print(f"Queueing generator prompt for {character.character_id}")
except Exception as e:
logger.exception("Generator error: %s", e)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error: {str(e)}")
return redirect(url_for('generator', preset=preset_slug))
_finalize = _make_finalize('characters', character.slug)
label = f"Generator: {character.name}"
job = _enqueue_job(label, workflow, _finalize)
@app.route('/generator/preset_info', methods=['GET'])
def generator_preset_info():
"""Return resolved entity names for a preset (for the summary panel)."""
slug = request.args.get('slug', '')
if not slug:
return {'error': 'slug required'}, 400
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'status': 'queued', 'job_id': job['id']}
preset = Preset.query.filter_by(slug=slug).first()
if not preset:
return {'error': 'not found'}, 404
flash("Generation queued.")
except Exception as e:
print(f"Generator error: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': str(e)}, 500
flash(f"Error: {str(e)}")
data = preset.data
info = {}
return render_template('generator.html', characters=characters, checkpoints=checkpoints,
actions=actions, outfits=outfits, scenes=scenes,
styles=styles, detailers=detailers, selected_ckpt=selected_ckpt)
# Character
char_cfg = data.get('character', {})
char_id = char_cfg.get('character_id')
if char_id == 'random':
info['character'] = 'Random'
elif char_id:
obj = _resolve_preset_entity('character', char_id)
info['character'] = obj.name if obj else char_id
else:
info['character'] = None
@app.route('/generator/preview_prompt', methods=['POST'])
def generator_preview_prompt():
char_slug = request.form.get('character')
if not char_slug:
return {'error': 'No character selected'}, 400
# Secondary entities
for key, label in [('outfit', 'outfit'), ('action', 'action'), ('style', 'style'),
('scene', 'scene'), ('detailer', 'detailer'), ('look', 'look')]:
cfg = data.get(key, {})
eid = cfg.get(f'{key}_id')
if eid == 'random':
info[label] = 'Random'
elif eid:
obj = _resolve_preset_entity(key, eid)
info[label] = obj.name if obj else eid
else:
info[label] = None
character = Character.query.filter_by(slug=char_slug).first()
if not character:
return {'error': 'Character not found'}, 404
# Checkpoint
ckpt_cfg = data.get('checkpoint', {})
ckpt_path = ckpt_cfg.get('checkpoint_path')
if ckpt_path == 'random':
info['checkpoint'] = 'Random'
elif ckpt_path:
info['checkpoint'] = ckpt_path.split('/')[-1].replace('.safetensors', '')
else:
info['checkpoint'] = 'Default'
action_slugs = request.form.getlist('action_slugs')
outfit_slugs = request.form.getlist('outfit_slugs')
scene_slugs = request.form.getlist('scene_slugs')
style_slugs = request.form.getlist('style_slugs')
detailer_slugs = request.form.getlist('detailer_slugs')
custom_positive = request.form.get('positive_prompt', '')
# Resolution
res_cfg = data.get('resolution', {})
if res_cfg.get('random'):
info['resolution'] = 'Random'
elif res_cfg.get('width') and res_cfg.get('height'):
info['resolution'] = f"{res_cfg['width']}x{res_cfg['height']}"
else:
info['resolution'] = 'Default'
sel_actions = Action.query.filter(Action.slug.in_(action_slugs)).all() if action_slugs else []
sel_outfits = Outfit.query.filter(Outfit.slug.in_(outfit_slugs)).all() if outfit_slugs else []
sel_scenes = Scene.query.filter(Scene.slug.in_(scene_slugs)).all() if scene_slugs else []
sel_styles = Style.query.filter(Style.slug.in_(style_slugs)).all() if style_slugs else []
sel_detailers = Detailer.query.filter(Detailer.slug.in_(detailer_slugs)).all() if detailer_slugs else []
prompts = build_prompt(character.data, default_fields=character.default_fields)
extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers)
combined = prompts["main"]
if extras:
combined = f"{combined}, {extras}"
if custom_positive:
combined = f"{custom_positive}, {combined}"
return {'prompt': combined, 'face': prompts['face'], 'hand': prompts['hand']}
return info

View File

@@ -1,7 +1,6 @@
import json
import os
import re
import time
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
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, _dedup_tags
from services.sync import sync_looks
from services.file_io import get_available_loras, _count_look_assignments
@@ -58,9 +57,18 @@ def register_routes(app):
@app.route('/looks')
def looks_index():
looks = Look.query.order_by(Look.name).all()
query = Look.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)
looks = query.order_by(Look.is_favourite.desc(), Look.name).all()
look_assignments = _count_look_assignments()
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments)
return render_template('looks/index.html', looks=looks, look_assignments=look_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/looks/rescan', methods=['POST'])
def rescan_looks():
@@ -144,8 +152,12 @@ def register_routes(app):
except ValueError:
pass
tags_raw = request.form.get('tags', '')
new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()]
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,
}
look.is_nsfw = new_data['tags']['nsfw']
look.data = new_data
flag_modified(look, 'data')
@@ -435,19 +447,32 @@ Character ID: {character_slug}"""
def create_look():
characters = Character.query.order_by(Character.name).all()
loras = get_available_loras('characters')
form_data = {}
if request.method == 'POST':
name = request.form.get('name', '').strip()
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
filename = f'{look_id}.json'
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
character_id = request.form.get('character_id', '') or None
lora_name = request.form.get('lora_lora_name', '')
lora_weight = float(request.form.get('lora_lora_weight', 1.0) or 1.0)
lora_triggers = request.form.get('lora_lora_triggers', '')
positive = request.form.get('positive', '')
negative = request.form.get('negative', '')
tags = [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()]
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,
}
form_data = {
'name': name, 'character_id': character_id,
'lora_lora_name': lora_name, 'lora_lora_weight': lora_weight,
'lora_lora_triggers': lora_triggers, 'positive': positive,
'negative': negative, 'tags': tags,
}
look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name.lower().replace(' ', '_'))
filename = f'{look_id}.json'
file_path = os.path.join(app.config['LOOKS_DIR'], filename)
data = {
'look_id': look_id,
@@ -459,20 +484,26 @@ Character ID: {character_slug}"""
'tags': tags
}
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
with open(file_path, 'w') as f:
json.dump(data, f, indent=2)
try:
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
with open(file_path, 'w') as f:
json.dump(data, f, indent=2)
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
character_id=character_id, data=data)
db.session.add(new_look)
db.session.commit()
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
new_look = Look(look_id=look_id, slug=slug, filename=filename, name=name,
character_id=character_id, data=data,
is_nsfw=tags.get('nsfw', False))
db.session.add(new_look)
db.session.commit()
flash(f'Look "{name}" created!')
return redirect(url_for('look_detail', slug=slug))
flash(f'Look "{name}" created!')
return redirect(url_for('look_detail', slug=slug))
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create look: {e}")
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
return render_template('looks/create.html', characters=characters, loras=loras)
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
@app.route('/get_missing_looks')
def get_missing_looks():
@@ -497,15 +528,16 @@ Character ID: {character_slug}"""
return redirect(url_for('looks_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
skipped = 0
job_ids = []
system_prompt = load_prompt('look_system.txt')
if not system_prompt:
flash('Look system prompt file not found.', 'error')
return redirect(url_for('looks_index'))
looks_dir = app.config['LOOKS_DIR']
for filename in os.listdir(lora_dir):
if not filename.endswith('.safetensors'):
continue
@@ -515,11 +547,11 @@ Character ID: {character_slug}"""
look_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{look_id}.json"
json_path = os.path.join(app.config['LOOKS_DIR'], json_filename)
json_path = os.path.join(looks_dir, json_filename)
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
@@ -535,54 +567,59 @@ Character ID: {character_slug}"""
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}")
logger.error("Error reading HTML %s: %s", html_filename, e)
try:
print(f"Asking LLM to describe look: {look_name}")
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
def make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing):
def task_fn(job):
prompt = f"Create a look profile for a character appearance LoRA based on the filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
look_data = json.loads(clean_json)
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
look_data = json.loads(clean_json)
look_data['look_id'] = look_id
look_data['look_name'] = look_name
look_data['look_id'] = look_id
look_data['look_name'] = look_name
if 'lora' not in look_data:
look_data['lora'] = {}
look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not look_data['lora'].get('lora_triggers'):
look_data['lora']['lora_triggers'] = name_base
if look_data['lora'].get('lora_weight') is None:
look_data['lora']['lora_weight'] = 0.8
if look_data['lora'].get('lora_weight_min') is None:
look_data['lora']['lora_weight_min'] = 0.7
if look_data['lora'].get('lora_weight_max') is None:
look_data['lora']['lora_weight_max'] = 1.0
if 'lora' not in look_data:
look_data['lora'] = {}
look_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not look_data['lora'].get('lora_triggers'):
look_data['lora']['lora_triggers'] = name_base
if look_data['lora'].get('lora_weight') is None:
look_data['lora']['lora_weight'] = 0.8
if look_data['lora'].get('lora_weight_min') is None:
look_data['lora']['lora_weight_min'] = 0.7
if look_data['lora'].get('lora_weight_max') is None:
look_data['lora']['lora_weight_max'] = 1.0
os.makedirs(app.config['LOOKS_DIR'], exist_ok=True)
with open(json_path, 'w') as f:
json.dump(look_data, f, indent=2)
os.makedirs(looks_dir, exist_ok=True)
with open(json_path, 'w') as f:
json.dump(look_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': look_name, 'action': 'overwritten' if is_existing else 'created'}
return task_fn
time.sleep(0.5)
job = _enqueue_task(f"Create look: {look_name}", make_task(filename, name_base, look_id, look_name, json_path, looks_dir, _lora_subfolder, html_content, system_prompt, is_existing))
job_ids.append(job['id'])
except Exception as e:
print(f"Error creating look for {filename}: {e}")
if job_ids:
def sync_task(job):
sync_looks()
job['result'] = {'synced': True}
_enqueue_task("Sync looks DB", sync_task)
if created_count > 0 or overwritten_count > 0:
sync_looks()
msg = f'Successfully processed looks: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No looks created or overwritten. {skipped_count} existing entries found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
return redirect(url_for('looks_index'))
return redirect(url_for('looks_index'))
@app.route('/look/<path:slug>/favourite', methods=['POST'])
def toggle_look_favourite(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
look.is_favourite = not look.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': look.is_favourite}
return redirect(url_for('look_detail', slug=slug))

View File

@@ -1,7 +1,6 @@
import json
import os
import re
import time
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Outfit, Action, 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_outfits
from services.file_io import get_available_loras, _count_outfit_lora_assignments
@@ -37,9 +36,18 @@ def register_routes(app):
@app.route('/outfits')
def outfits_index():
outfits = Outfit.query.order_by(Outfit.name).all()
query = Outfit.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)
outfits = query.order_by(Outfit.is_favourite.desc(), Outfit.name).all()
lora_assignments = _count_outfit_lora_assignments()
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments)
return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/outfits/rescan', methods=['POST'])
def rescan_outfits():
@@ -53,20 +61,24 @@ def register_routes(app):
clothing_lora_dir = ((_s.lora_dir_outfits if _s else None) or '/ImageModels/lora/Illustrious/Clothing').rstrip('/')
_lora_subfolder = os.path.basename(clothing_lora_dir)
if not os.path.exists(clothing_lora_dir):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Clothing LoRA directory not found.'}, 400
flash('Clothing LoRA directory not found.', 'error')
return redirect(url_for('outfits_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
system_prompt = load_prompt('outfit_system.txt')
if not system_prompt:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'error': 'Outfit system prompt file not found.'}, 500
flash('Outfit system prompt file not found.', 'error')
return redirect(url_for('outfits_index'))
for filename in os.listdir(clothing_lora_dir):
job_ids = []
skipped = 0
for filename in sorted(os.listdir(clothing_lora_dir)):
if not filename.endswith('.safetensors'):
continue
@@ -79,11 +91,11 @@ def register_routes(app):
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
html_path = os.path.join(clothing_lora_dir, html_filename)
# Read HTML companion file if it exists
html_path = os.path.join(clothing_lora_dir, f"{name_base}.html")
html_content = ""
if os.path.exists(html_path):
try:
@@ -94,57 +106,59 @@ def register_routes(app):
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}")
except Exception:
pass
try:
print(f"Asking LLM to describe outfit: {outfit_name}")
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{filename}'"
if html_content:
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_content[:3000]}\n###"
def make_task(fn, oid, oname, jp, lsf, html_ctx, sys_prompt, is_exist):
def task_fn(job):
prompt = f"Create an outfit profile for a clothing LoRA based on the filename: '{fn}'"
if html_ctx:
prompt += f"\n\nHere is descriptive text extracted from an associated HTML file:\n###\n{html_ctx[:3000]}\n###"
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
outfit_data = json.loads(clean_json)
llm_response = call_llm(prompt, sys_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
outfit_data = json.loads(clean_json)
outfit_data['outfit_id'] = outfit_id
outfit_data['outfit_name'] = outfit_name
outfit_data['outfit_id'] = oid
outfit_data['outfit_name'] = oname
if 'lora' not in outfit_data:
outfit_data['lora'] = {}
outfit_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not outfit_data['lora'].get('lora_triggers'):
outfit_data['lora']['lora_triggers'] = name_base
if outfit_data['lora'].get('lora_weight') is None:
outfit_data['lora']['lora_weight'] = 0.8
if outfit_data['lora'].get('lora_weight_min') is None:
outfit_data['lora']['lora_weight_min'] = 0.7
if outfit_data['lora'].get('lora_weight_max') is None:
outfit_data['lora']['lora_weight_max'] = 1.0
if 'lora' not in outfit_data:
outfit_data['lora'] = {}
outfit_data['lora']['lora_name'] = f"Illustrious/{lsf}/{fn}"
if not outfit_data['lora'].get('lora_triggers'):
outfit_data['lora']['lora_triggers'] = fn.rsplit('.', 1)[0]
if outfit_data['lora'].get('lora_weight') is None:
outfit_data['lora']['lora_weight'] = 0.8
if outfit_data['lora'].get('lora_weight_min') is None:
outfit_data['lora']['lora_weight_min'] = 0.7
if outfit_data['lora'].get('lora_weight_max') is None:
outfit_data['lora']['lora_weight_max'] = 1.0
os.makedirs(app.config['CLOTHING_DIR'], exist_ok=True)
with open(json_path, 'w') as f:
json.dump(outfit_data, f, indent=2)
os.makedirs(os.path.dirname(jp), exist_ok=True)
with open(jp, 'w') as f:
json.dump(outfit_data, f, indent=2)
if is_existing:
overwritten_count += 1
else:
created_count += 1
job['result'] = {'name': oname, 'action': 'overwritten' if is_exist else 'created'}
return task_fn
time.sleep(0.5)
job = _enqueue_task(
f"Create outfit: {outfit_name}",
make_task(filename, outfit_id, outfit_name, json_path,
_lora_subfolder, html_content, system_prompt, is_existing)
)
job_ids.append(job['id'])
except Exception as e:
print(f"Error creating outfit for {filename}: {e}")
# Enqueue a sync task to run after all creates
if job_ids:
def sync_task(job):
sync_outfits()
job['result'] = {'synced': True}
_enqueue_task("Sync outfits DB", sync_task)
if created_count > 0 or overwritten_count > 0:
sync_outfits()
msg = f'Successfully processed outfits: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No outfits created or overwritten. {skipped_count} existing entries found.')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'queued': len(job_ids), 'skipped': skipped}
flash(f'Queued {len(job_ids)} outfit creation tasks ({skipped} skipped). Watch progress in the queue.')
return redirect(url_for('outfits_index'))
def _get_linked_characters_for_outfit(outfit):
@@ -232,9 +246,12 @@ 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]
# Update Tags (structured dict)
new_data['tags'] = {
'outfit_type': request.form.get('tag_outfit_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
outfit.is_nsfw = new_data['tags']['nsfw']
outfit.data = new_data
flag_modified(outfit, "data")
@@ -409,12 +426,16 @@ def register_routes(app):
@app.route('/outfit/create', methods=['GET', 'POST'])
def create_outfit():
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}
# Auto-generate slug from name if not provided
if not slug:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_')
@@ -435,13 +456,13 @@ 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('outfits/create.html', form_data=form_data)
# Generate JSON with LLM
system_prompt = load_prompt('outfit_system.txt')
if not system_prompt:
flash("System prompt file not found.")
return redirect(request.url)
return render_template('outfits/create.html', form_data=form_data)
try:
llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt)
@@ -477,7 +498,7 @@ def register_routes(app):
except Exception as e:
print(f"LLM error: {e}")
flash(f"Failed to generate outfit profile: {e}")
return redirect(request.url)
return render_template('outfits/create.html', form_data=form_data)
else:
# Create blank outfit template
outfit_data = {
@@ -523,9 +544,9 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create outfit: {e}")
return redirect(request.url)
return render_template('outfits/create.html', form_data=form_data)
return render_template('outfits/create.html')
return render_template('outfits/create.html', form_data=form_data)
@app.route('/outfit/<path:slug>/save_defaults', methods=['POST'])
def save_outfit_defaults(slug):
@@ -601,3 +622,12 @@ def register_routes(app):
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/outfit/<path:slug>/favourite', methods=['POST'])
def toggle_outfit_favourite(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
outfit.is_favourite = not outfit.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': outfit.is_favourite}
return redirect(url_for('outfit_detail', slug=slug))

View File

@@ -149,6 +149,8 @@ def register_routes(app):
'use_lora': request.form.get('outfit_use_lora') == 'on'},
'action': {'action_id': _entity_id(request.form.get('action_id')),
'use_lora': request.form.get('action_use_lora') == 'on',
'suppress_wardrobe': {'true': True, 'false': False, 'random': 'random'}.get(
request.form.get('act_suppress_wardrobe')),
'fields': {k: _tog(request.form.get(f'act_{k}', 'true'))
for k in ['base', 'head', 'upper_body', 'lower_body', 'hands', 'feet', 'additional']}},
'style': {'style_id': _entity_id(request.form.get('style_id')),
@@ -247,11 +249,15 @@ def register_routes(app):
@app.route('/preset/create', methods=['GET', 'POST'])
def create_preset():
form_data = {}
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
use_llm = request.form.get('use_llm') == 'on'
form_data = {'name': name, 'description': description, 'use_llm': use_llm}
safe_id = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') or 'preset'
safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id)
base_id = safe_id
@@ -265,7 +271,7 @@ def register_routes(app):
system_prompt = load_prompt('preset_system.txt')
if not system_prompt:
flash('Preset system prompt file not found.', 'error')
return redirect(request.url)
return render_template('presets/create.html', form_data=form_data)
try:
llm_response = call_llm(
f"Create a preset profile named '{name}' based on this description: {description}",
@@ -276,7 +282,7 @@ def register_routes(app):
except Exception as e:
logger.exception("LLM error creating preset: %s", e)
flash(f"AI generation failed: {e}", 'error')
return redirect(request.url)
return render_template('presets/create.html', form_data=form_data)
else:
preset_data = {
'character': {'character_id': 'random', 'use_lora': True,
@@ -314,7 +320,7 @@ def register_routes(app):
flash(f"Preset '{name}' created!")
return redirect(url_for('edit_preset', slug=safe_slug))
return render_template('presets/create.html')
return render_template('presets/create.html', form_data=form_data)
@app.route('/get_missing_presets')
def get_missing_presets():

View File

@@ -1,10 +1,14 @@
import logging
from services.job_queue import (
_job_queue_lock, _job_queue, _job_history, _queue_worker_event,
_job_queue_lock, _job_queue, _llm_queue, _job_history,
_queue_worker_event, _llm_worker_event,
)
logger = logging.getLogger('gaze')
# Both queues for iteration
_ALL_QUEUES = (_job_queue, _llm_queue)
def register_routes(app):
@@ -12,23 +16,27 @@ def register_routes(app):
def api_queue_list():
"""Return the current queue as JSON."""
with _job_queue_lock:
jobs = [
{
'id': j['id'],
'label': j['label'],
'status': j['status'],
'error': j['error'],
'created_at': j['created_at'],
}
for j in _job_queue
]
jobs = []
for q in _ALL_QUEUES:
for j in q:
jobs.append({
'id': j['id'],
'label': j['label'],
'status': j['status'],
'error': j['error'],
'created_at': j['created_at'],
'job_type': j.get('job_type', 'comfyui'),
})
return {'jobs': jobs, 'count': len(jobs)}
@app.route('/api/queue/count')
def api_queue_count():
"""Return just the count of active (non-done, non-failed) jobs."""
with _job_queue_lock:
count = sum(1 for j in _job_queue if j['status'] in ('pending', 'processing', 'paused'))
count = sum(
1 for q in _ALL_QUEUES for j in q
if j['status'] in ('pending', 'processing', 'paused')
)
return {'count': count}
@app.route('/api/queue/<job_id>/remove', methods=['POST'])
@@ -40,10 +48,12 @@ def register_routes(app):
return {'error': 'Job not found'}, 404
if job['status'] == 'processing':
return {'error': 'Cannot remove a job that is currently processing'}, 400
try:
_job_queue.remove(job)
except ValueError:
pass # Already not in queue
for q in _ALL_QUEUES:
try:
q.remove(job)
break
except ValueError:
continue
job['status'] = 'removed'
return {'status': 'ok'}
@@ -58,24 +68,29 @@ def register_routes(app):
job['status'] = 'paused'
elif job['status'] == 'paused':
job['status'] = 'pending'
_queue_worker_event.set()
# Signal the appropriate worker
if job.get('job_type') == 'llm':
_llm_worker_event.set()
else:
_queue_worker_event.set()
else:
return {'error': f'Cannot pause/resume job with status {job["status"]}'}, 400
return {'status': 'ok', 'new_status': job['status']}
@app.route('/api/queue/clear', methods=['POST'])
def api_queue_clear():
"""Clear all pending jobs from the queue (allows current processing job to finish)."""
"""Clear all pending jobs from the queue (allows current processing jobs to finish)."""
removed_count = 0
with _job_queue_lock:
pending_jobs = [j for j in _job_queue if j['status'] == 'pending']
for job in pending_jobs:
try:
_job_queue.remove(job)
job['status'] = 'removed'
removed_count += 1
except ValueError:
pass
for q in _ALL_QUEUES:
pending_jobs = [j for j in q if j['status'] == 'pending']
for job in pending_jobs:
try:
q.remove(job)
job['status'] = 'removed'
removed_count += 1
except ValueError:
pass
logger.info("Cleared %d pending jobs from queue", removed_count)
return {'status': 'ok', 'removed_count': removed_count}
@@ -91,7 +106,8 @@ def register_routes(app):
'label': job['label'],
'status': job['status'],
'error': job['error'],
'comfy_prompt_id': job['comfy_prompt_id'],
'job_type': job.get('job_type', 'comfyui'),
'comfy_prompt_id': job.get('comfy_prompt_id'),
}
if job.get('result'):
resp['result'] = job['result']

202
routes/regenerate.py Normal file
View File

@@ -0,0 +1,202 @@
import json
import os
import logging
from flask import current_app
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint
from services.llm import load_prompt, call_llm
from services.sync import _sync_nsfw_from_tags
from services.job_queue import _enqueue_task
logger = logging.getLogger('gaze')
# Map category string to (model class, id_field, config_dir_key)
_CATEGORY_MAP = {
'characters': (Character, 'character_id', 'CHARACTERS_DIR'),
'outfits': (Outfit, 'outfit_id', 'CLOTHING_DIR'),
'actions': (Action, 'action_id', 'ACTIONS_DIR'),
'styles': (Style, 'style_id', 'STYLES_DIR'),
'scenes': (Scene, 'scene_id', 'SCENES_DIR'),
'detailers': (Detailer, 'detailer_id', 'DETAILERS_DIR'),
'looks': (Look, 'look_id', 'LOOKS_DIR'),
}
# Fields to preserve from the original data (never overwritten by LLM output)
_PRESERVE_KEYS = {
'lora', 'participants', 'suppress_wardrobe',
'character_id', 'character_name',
'outfit_id', 'outfit_name',
'action_id', 'action_name',
'style_id', 'style_name',
'scene_id', 'scene_name',
'detailer_id', 'detailer_name',
'look_id', 'look_name',
}
def register_routes(app):
@app.route('/api/<category>/<path:slug>/regenerate_tags', methods=['POST'])
def regenerate_tags(category, slug):
if category not in _CATEGORY_MAP:
return {'error': f'Unknown category: {category}'}, 400
model_class, id_field, dir_key = _CATEGORY_MAP[category]
entity = model_class.query.filter_by(slug=slug).first()
if not entity:
return {'error': 'Not found'}, 404
system_prompt = load_prompt('regenerate_tags_system.txt')
if not system_prompt:
return {'error': 'Regenerate tags system prompt not found'}, 500
original_data = entity.data.copy()
try:
prompt = (
f"Regenerate the prompt tags for this {category.rstrip('s')} resource.\n"
f"Current JSON:\n{json.dumps(original_data, indent=2)}"
)
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
new_data = json.loads(clean_json)
except Exception as e:
logger.exception(f"Regenerate tags LLM error for {category}/{slug}")
return {'error': f'LLM error: {str(e)}'}, 500
# Preserve protected fields from original
for key in _PRESERVE_KEYS:
if key in original_data:
new_data[key] = original_data[key]
# Update DB
entity.data = new_data
flag_modified(entity, 'data')
_sync_nsfw_from_tags(entity, new_data)
db.session.commit()
# Write back to JSON file
if entity.filename:
file_path = os.path.join(current_app.config[dir_key], entity.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True, 'data': new_data}
@app.route('/admin/migrate_tags', methods=['POST'])
def migrate_tags():
"""One-time migration: convert old list-format tags to new dict format."""
migrated = 0
for category, (model_class, id_field, dir_key) in _CATEGORY_MAP.items():
entities = model_class.query.all()
for entity in entities:
tags = entity.data.get('tags')
if isinstance(tags, list) or tags is None:
new_data = entity.data.copy()
new_data['tags'] = {'nsfw': False}
entity.data = new_data
flag_modified(entity, 'data')
# Write back to JSON file
if entity.filename:
file_path = os.path.join(current_app.config[dir_key], entity.filename)
try:
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
except Exception as e:
logger.warning(f"Could not write {file_path}: {e}")
migrated += 1
# Also handle checkpoints
for ckpt in Checkpoint.query.all():
data = ckpt.data or {}
tags = data.get('tags')
if isinstance(tags, list) or tags is None:
new_data = data.copy()
new_data['tags'] = {'nsfw': False}
ckpt.data = new_data
flag_modified(ckpt, 'data')
migrated += 1
db.session.commit()
logger.info(f"Migrated {migrated} resources from list tags to dict tags")
return {'success': True, 'migrated': migrated}
def _make_regen_task(category, slug, name, system_prompt):
"""Factory: create a tag regeneration task function for one entity."""
def task_fn(job):
model_class, id_field, dir_key = _CATEGORY_MAP[category]
entity = model_class.query.filter_by(slug=slug).first()
if not entity:
raise Exception(f'{category}/{slug} not found')
original_data = entity.data.copy()
prompt = (
f"Regenerate the prompt tags for this {category.rstrip('s')} resource.\n"
f"Current JSON:\n{json.dumps(original_data, indent=2)}"
)
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
new_data = json.loads(clean_json)
for key in _PRESERVE_KEYS:
if key in original_data:
new_data[key] = original_data[key]
entity.data = new_data
flag_modified(entity, 'data')
_sync_nsfw_from_tags(entity, new_data)
db.session.commit()
if entity.filename:
file_path = os.path.join(current_app.config[dir_key], entity.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
job['result'] = {'entity': name, 'status': 'updated'}
return task_fn
@app.route('/admin/bulk_regenerate_tags/<category>', methods=['POST'])
def bulk_regenerate_tags_category(category):
"""Queue LLM tag regeneration for all resources in a single category."""
if category not in _CATEGORY_MAP:
return {'error': f'Unknown category: {category}'}, 400
system_prompt = load_prompt('regenerate_tags_system.txt')
if not system_prompt:
return {'error': 'Regenerate tags system prompt not found'}, 500
model_class, id_field, dir_key = _CATEGORY_MAP[category]
entities = model_class.query.all()
job_ids = []
for entity in entities:
job = _enqueue_task(
f"Regen tags: {entity.name} ({category})",
_make_regen_task(category, entity.slug, entity.name, system_prompt)
)
job_ids.append(job['id'])
return {'success': True, 'queued': len(job_ids), 'job_ids': job_ids}
@app.route('/admin/bulk_regenerate_tags', methods=['POST'])
def bulk_regenerate_tags():
"""Queue LLM tag regeneration for all resources across all categories."""
system_prompt = load_prompt('regenerate_tags_system.txt')
if not system_prompt:
return {'error': 'Regenerate tags system prompt not found'}, 500
job_ids = []
for category, (model_class, id_field, dir_key) in _CATEGORY_MAP.items():
entities = model_class.query.all()
for entity in entities:
job = _enqueue_task(
f"Regen tags: {entity.name} ({category})",
_make_regen_task(category, entity.slug, entity.name, system_prompt)
)
job_ids.append(job['id'])
return {'success': True, 'queued': len(job_ids), 'job_ids': job_ids}

View File

@@ -1,7 +1,6 @@
import json
import os
import re
import time
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
@@ -10,7 +9,7 @@ from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Scene, Outfit, Action, Style, 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_scenes
from services.file_io import get_available_loras
@@ -37,8 +36,17 @@ def register_routes(app):
@app.route('/scenes')
def scenes_index():
scenes = Scene.query.order_by(Scene.name).all()
return render_template('scenes/index.html', scenes=scenes)
query = Scene.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)
scenes = query.order_by(Scene.is_favourite.desc(), Scene.name).all()
return render_template('scenes/index.html', scenes=scenes, favourite_filter=fav or '', nsfw_filter=nsfw)
@app.route('/scenes/rescan', methods=['POST'])
def rescan_scenes():
@@ -117,9 +125,12 @@ 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 t in tags_raw.split(',') if t.strip()]
# Update Tags (structured dict)
new_data['tags'] = {
'scene_type': request.form.get('tag_scene_type', '').strip(),
'nsfw': 'tag_nsfw' in request.form,
}
scene.is_nsfw = new_data['tags']['nsfw']
scene.data = new_data
flag_modified(scene, "data")
@@ -332,15 +343,16 @@ def register_routes(app):
return redirect(url_for('scenes_index'))
overwrite = request.form.get('overwrite') == 'true'
created_count = 0
skipped_count = 0
overwritten_count = 0
skipped = 0
job_ids = []
system_prompt = load_prompt('scene_system.txt')
if not system_prompt:
flash('Scene system prompt file not found.', 'error')
return redirect(url_for('scenes_index'))
scenes_dir = app.config['SCENES_DIR']
for filename in os.listdir(backgrounds_lora_dir):
if filename.endswith('.safetensors'):
name_base = filename.rsplit('.', 1)[0]
@@ -348,11 +360,11 @@ def register_routes(app):
scene_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title()
json_filename = f"{scene_id}.json"
json_path = os.path.join(app.config['SCENES_DIR'], json_filename)
json_path = os.path.join(scenes_dir, json_filename)
is_existing = os.path.exists(json_path)
if is_existing and not overwrite:
skipped_count += 1
skipped += 1
continue
html_filename = f"{name_base}.html"
@@ -362,74 +374,69 @@ def register_routes(app):
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}")
logger.error("Error reading HTML %s: %s", html_filename, e)
try:
print(f"Asking LLM to describe scene: {scene_name}")
prompt = f"Describe a scene 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###"
def make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing):
def task_fn(job):
prompt = f"Describe a scene 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###"
llm_response = call_llm(prompt, system_prompt)
llm_response = call_llm(prompt, system_prompt)
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
scene_data = json.loads(clean_json)
# Clean response
clean_json = llm_response.replace('```json', '').replace('```', '').strip()
scene_data = json.loads(clean_json)
scene_data['scene_id'] = scene_id
scene_data['scene_name'] = scene_name
# Enforce system values while preserving LLM-extracted metadata
scene_data['scene_id'] = scene_id
scene_data['scene_name'] = scene_name
if 'lora' not in scene_data: scene_data['lora'] = {}
scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if 'lora' not in scene_data: scene_data['lora'] = {}
scene_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}"
if not scene_data['lora'].get('lora_triggers'):
scene_data['lora']['lora_triggers'] = name_base
if scene_data['lora'].get('lora_weight') is None:
scene_data['lora']['lora_weight'] = 1.0
if scene_data['lora'].get('lora_weight_min') is None:
scene_data['lora']['lora_weight_min'] = 0.7
if scene_data['lora'].get('lora_weight_max') is None:
scene_data['lora']['lora_weight_max'] = 1.0
if not scene_data['lora'].get('lora_triggers'):
scene_data['lora']['lora_triggers'] = name_base
if scene_data['lora'].get('lora_weight') is None:
scene_data['lora']['lora_weight'] = 1.0
if scene_data['lora'].get('lora_weight_min') is None:
scene_data['lora']['lora_weight_min'] = 0.7
if scene_data['lora'].get('lora_weight_max') is None:
scene_data['lora']['lora_weight_max'] = 1.0
with open(json_path, 'w') as f:
json.dump(scene_data, f, indent=2)
with open(json_path, 'w') as f:
json.dump(scene_data, f, indent=2)
job['result'] = {'name': scene_name, 'action': 'overwritten' if is_existing else 'created'}
return task_fn
if is_existing:
overwritten_count += 1
else:
created_count += 1
job = _enqueue_task(f"Create scene: {scene_name}", make_task(filename, name_base, scene_id, scene_name, json_path, _lora_subfolder, html_content, system_prompt, is_existing))
job_ids.append(job['id'])
# Small delay to avoid API rate limits if many files
time.sleep(0.5)
except Exception as e:
print(f"Error creating scene for {filename}: {e}")
if created_count > 0 or overwritten_count > 0:
sync_scenes()
msg = f'Successfully processed scenes: {created_count} created, {overwritten_count} overwritten.'
if skipped_count > 0:
msg += f' (Skipped {skipped_count} existing)'
flash(msg)
else:
flash(f'No scenes created or overwritten. {skipped_count} existing scenes found.')
if job_ids:
def sync_task(job):
sync_scenes()
job['result'] = {'synced': True}
_enqueue_task("Sync scenes 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)} scene tasks ({skipped} skipped).')
return redirect(url_for('scenes_index'))
@app.route('/scene/create', methods=['GET', 'POST'])
def create_scene():
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('_')
@@ -478,9 +485,9 @@ def register_routes(app):
except Exception as e:
print(f"Save error: {e}")
flash(f"Failed to create scene: {e}")
return redirect(request.url)
return render_template('scenes/create.html', form_data=form_data)
return render_template('scenes/create.html')
return render_template('scenes/create.html', form_data=form_data)
@app.route('/scene/<path:slug>/clone', methods=['POST'])
def clone_scene(slug):
@@ -538,3 +545,12 @@ def register_routes(app):
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/scene/<path:slug>/favourite', methods=['POST'])
def toggle_scene_favourite(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
scene.is_favourite = not scene.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': scene.is_favourite}
return redirect(url_for('scene_detail', slug=slug))

209
routes/search.py Normal file
View File

@@ -0,0 +1,209 @@
import json
import os
import logging
from flask import render_template, request, current_app
from models import (
db, Character, Look, Outfit, Action, Style, Scene, Detailer, Checkpoint,
)
logger = logging.getLogger('gaze')
# Category config: (model_class, id_field, name_field, url_prefix, detail_route)
_SEARCH_CATEGORIES = {
'characters': (Character, 'character_id', 'character_name', '/character', 'detail'),
'looks': (Look, 'look_id', 'look_name', '/look', 'look_detail'),
'outfits': (Outfit, 'outfit_id', 'outfit_name', '/outfit', 'outfit_detail'),
'actions': (Action, 'action_id', 'action_name', '/action', 'action_detail'),
'styles': (Style, 'style_id', 'style_name', '/style', 'style_detail'),
'scenes': (Scene, 'scene_id', 'scene_name', '/scene', 'scene_detail'),
'detailers': (Detailer, 'detailer_id', 'detailer_name', '/detailer', 'detailer_detail'),
'checkpoints': (Checkpoint, 'checkpoint_id', 'checkpoint_name', '/checkpoint', 'checkpoint_detail'),
}
def register_routes(app):
def _search_resources(query_str, category_filter='all', nsfw_filter='all'):
"""Search resources by name, tag values, and prompt field content."""
results = []
q = query_str.lower()
categories = _SEARCH_CATEGORIES if category_filter == 'all' else {category_filter: _SEARCH_CATEGORIES.get(category_filter)}
categories = {k: v for k, v in categories.items() if v}
for cat_name, (model_class, id_field, name_field, url_prefix, detail_route) in categories.items():
entities = model_class.query.all()
for entity in entities:
# Apply NSFW filter
if nsfw_filter == 'sfw' and getattr(entity, 'is_nsfw', False):
continue
if nsfw_filter == 'nsfw' and not getattr(entity, 'is_nsfw', False):
continue
match_context = None
score = 0
# 1. Name match (highest priority)
if q in entity.name.lower():
score = 100 if entity.name.lower() == q else 90
match_context = f"Name: {entity.name}"
# 2. Tag match
if not match_context:
data = entity.data or {}
tags = data.get('tags', {})
if isinstance(tags, dict):
for key, val in tags.items():
if key == 'nsfw':
continue
if isinstance(val, str) and q in val.lower():
score = 70
match_context = f"Tag {key}: {val}"
break
elif isinstance(val, list):
for item in val:
if isinstance(item, str) and q in item.lower():
score = 70
match_context = f"Tag {key}: {item}"
break
# 3. Prompt field match (search JSON data values)
if not match_context:
data_str = json.dumps(entity.data or {}).lower()
if q in data_str:
score = 30
# Find which field matched for context
data = entity.data or {}
for section_key, section_val in data.items():
if section_key in ('lora', 'tags'):
continue
if isinstance(section_val, dict):
for field_key, field_val in section_val.items():
if isinstance(field_val, str) and q in field_val.lower():
match_context = f"{section_key}.{field_key}: ...{field_val[:80]}..."
break
elif isinstance(section_val, str) and q in section_val.lower():
match_context = f"{section_key}: ...{section_val[:80]}..."
if match_context:
break
if not match_context:
match_context = "Matched in data"
if match_context:
results.append({
'type': cat_name,
'slug': entity.slug,
'name': entity.name,
'match_context': match_context,
'score': score,
'is_favourite': getattr(entity, 'is_favourite', False),
'is_nsfw': getattr(entity, 'is_nsfw', False),
'image_path': entity.image_path,
'detail_route': detail_route,
})
results.sort(key=lambda x: (-x['score'], x['name'].lower()))
return results
def _search_images(query_str, nsfw_filter='all'):
"""Search gallery images by prompt metadata in sidecar JSON files."""
results = []
q = query_str.lower()
upload_folder = app.config['UPLOAD_FOLDER']
gallery_cats = ['characters', 'actions', 'outfits', 'scenes', 'styles',
'detailers', 'checkpoints', 'looks', 'presets', 'generator']
for cat in gallery_cats:
cat_folder = os.path.join(upload_folder, cat)
if not os.path.isdir(cat_folder):
continue
try:
slugs = os.listdir(cat_folder)
except OSError:
continue
for item_slug in slugs:
item_folder = os.path.join(cat_folder, item_slug)
if not os.path.isdir(item_folder):
continue
try:
files = os.listdir(item_folder)
except OSError:
continue
for filename in files:
if not filename.lower().endswith('.json'):
continue
# This is a sidecar JSON
sidecar_path = os.path.join(item_folder, filename)
try:
with open(sidecar_path) as f:
sidecar = json.load(f)
except (OSError, json.JSONDecodeError):
continue
# NSFW filter
if nsfw_filter == 'sfw' and sidecar.get('is_nsfw'):
continue
if nsfw_filter == 'nsfw' and not sidecar.get('is_nsfw'):
continue
# Search positive/negative prompts stored in sidecar
sidecar_str = json.dumps(sidecar).lower()
if q in sidecar_str:
img_filename = filename.rsplit('.', 1)[0]
# Find the actual image file
img_path = None
for ext in ('.png', '.jpg', '.jpeg', '.webp'):
candidate = f"{cat}/{item_slug}/{img_filename}{ext}"
if os.path.isfile(os.path.join(upload_folder, candidate)):
img_path = candidate
break
if img_path:
results.append({
'path': img_path,
'category': cat,
'slug': item_slug,
'is_favourite': sidecar.get('is_favourite', False),
'is_nsfw': sidecar.get('is_nsfw', False),
'match_context': f"Found in sidecar metadata",
})
if len(results) >= 50:
break
return results[:50]
@app.route('/search')
def search():
q = request.args.get('q', '').strip()
category = request.args.get('category', 'all')
nsfw = request.args.get('nsfw', 'all')
search_type = request.args.get('type', 'all')
resources = []
images = []
if q:
if search_type in ('all', 'resources'):
resources = _search_resources(q, category, nsfw)
if search_type in ('all', 'images'):
images = _search_images(q, nsfw)
# Group resources by type
grouped = {}
for r in resources:
grouped.setdefault(r['type'], []).append(r)
return render_template(
'search.html',
query=q,
category=category,
nsfw_filter=nsfw,
search_type=search_type,
grouped_resources=grouped,
images=images,
total_resources=len(resources),
total_images=len(images),
)

View File

@@ -70,7 +70,6 @@ def register_routes(app):
if category == 'outfits':
wardrobe = entity.data.get('wardrobe', {})
outfit_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
wardrobe_parts = [v for v in wardrobe.values() if isinstance(v, str) and v]
char_parts = []
face_parts = []
@@ -83,7 +82,7 @@ def register_routes(app):
face_parts = [v for v in [identity.get('head'),
defaults.get('expression')] if v]
hand_parts = [v for v in [wardrobe.get('hands')] if v]
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags
main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts
return {
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
'face': _dedup_tags(', '.join(face_parts)),
@@ -93,7 +92,6 @@ def register_routes(app):
if category == 'actions':
action_data = entity.data.get('action', {})
action_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
from utils import _BODY_GROUP_KEYS
pose_parts = [action_data.get(k, '') for k in _BODY_GROUP_KEYS if action_data.get(k)]
expr_parts = [action_data.get('head', '')] if action_data.get('head') else []
@@ -104,7 +102,7 @@ def register_routes(app):
identity = character.data.get('identity', {})
char_parts = [v for v in [identity.get('base'), identity.get('head')] if v]
face_parts = [v for v in [identity.get('head')] + expr_parts if v]
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags
main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts
return {
'main': _dedup_tags(', '.join(p for p in main_parts if p)),
'face': _dedup_tags(', '.join(face_parts)),
@@ -113,20 +111,19 @@ def register_routes(app):
# styles / scenes / detailers
entity_triggers = entity.data.get('lora', {}).get('lora_triggers', '')
tags = entity.data.get('tags', [])
if category == 'styles':
sdata = entity.data.get('style', {})
artist = f"by {sdata['artist_name']}" if sdata.get('artist_name') else ''
style_tags = sdata.get('artistic_style', '')
entity_parts = [p for p in [entity_triggers, artist, style_tags] + tags if p]
entity_parts = [p for p in [entity_triggers, artist, style_tags] if p]
elif category == 'scenes':
sdata = entity.data.get('scene', {})
scene_parts = [v for v in sdata.values() if isinstance(v, str) and v]
entity_parts = [p for p in [entity_triggers] + scene_parts + tags if p]
entity_parts = [p for p in [entity_triggers] + scene_parts if p]
else: # detailers
det_prompt = entity.data.get('prompt', '')
entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p]
entity_parts = [p for p in [entity_triggers, det_prompt] if p]
char_data_no_lora = _get_character_data_without_lora(character)
base = build_prompt(char_data_no_lora, [], character.default_fields) if char_data_no_lora else {'main': '', 'face': '', 'hand': '', 'feet': ''}

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))