import json import logging import os import re from flask import flash, jsonify, redirect, render_template, request, session, url_for from sqlalchemy.orm.attributes import flag_modified 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_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 from routes.shared import register_common_routes, apply_library_filters logger = logging.getLogger('gaze') def register_routes(app): register_common_routes(app, 'characters') @app.route('/') def index(): characters, fav, nsfw = apply_library_filters(Character.query, Character) return render_template('index.html', characters=characters, favourite_filter=fav, nsfw_filter=nsfw) @app.route('/rescan', methods=['POST']) def rescan(): sync_characters() flash('Database synced with character files.') return redirect(url_for('index')) @app.route('/character/') def detail(slug): character = Character.query.filter_by(slug=slug).first_or_404() all_outfits = Outfit.query.order_by(Outfit.name).all() outfit_map = {o.outfit_id: o for o in all_outfits} # Helper function for template to get outfit by ID def get_outfit_by_id(outfit_id): return outfit_map.get(outfit_id) # Load state from session preferences = session.get(f'prefs_{slug}') preview_image = session.get(f'preview_{slug}') extra_positive = session.get(f'extra_pos_{slug}', '') extra_negative = session.get(f'extra_neg_{slug}', '') return render_template('detail.html', character=character, preferences=preferences, preview_image=preview_image, all_outfits=all_outfits, outfit_map=outfit_map, get_outfit_by_id=get_outfit_by_id, extra_positive=extra_positive, extra_negative=extra_negative) @app.route('/character//transfer', methods=['GET', 'POST']) def transfer_character(slug): character = Character.query.filter_by(slug=slug).first_or_404() if request.method == 'POST': target_type = request.form.get('target_type') new_name = request.form.get('new_name', '').strip() use_llm = request.form.get('use_llm') == 'on' if not new_name: flash('New name is required for transfer') return redirect(url_for('transfer_character', slug=slug)) # Validate new name length and content if len(new_name) > 100: flash('New name must be 100 characters or less') return redirect(url_for('transfer_character', slug=slug)) # Validate target type VALID_TARGET_TYPES = {'look', 'outfit', 'action', 'style', 'scene', 'detailer'} if target_type not in VALID_TARGET_TYPES: flash('Invalid target type') return redirect(url_for('transfer_character', slug=slug)) # Generate new slug from name new_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_name.lower()).strip('_') safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_slug) if not safe_slug: safe_slug = 'transferred' # Find available filename base_slug = safe_slug counter = 1 target_dir = None model_class = None # Map target type to directory and model if target_type == 'look': target_dir = app.config['LOOKS_DIR'] model_class = Look id_field = 'look_id' elif target_type == 'outfit': target_dir = app.config['CLOTHING_DIR'] model_class = Outfit id_field = 'outfit_id' elif target_type == 'action': target_dir = app.config['ACTIONS_DIR'] model_class = Action id_field = 'action_id' elif target_type == 'style': target_dir = app.config['STYLES_DIR'] model_class = Style id_field = 'style_id' elif target_type == 'scene': target_dir = app.config['SCENES_DIR'] model_class = Scene id_field = 'scene_id' elif target_type == 'detailer': target_dir = app.config['DETAILERS_DIR'] model_class = Detailer id_field = 'detailer_id' else: flash('Invalid target type') return redirect(url_for('transfer_character', slug=slug)) # Check for existing file while os.path.exists(os.path.join(target_dir, f"{safe_slug}.json")): safe_slug = f"{base_slug}_{counter}" counter += 1 if use_llm: # Use LLM to regenerate JSON for new type try: # Create prompt for LLM to convert character to target type system_prompt = load_prompt('transfer_system.txt') if not system_prompt: system_prompt = f"""You are an AI assistant that converts character profiles to {target_type} profiles. Convert the following character profile into a {target_type} profile. A {target_type} should focus on {target_type}-specific details. Keep the core identity but adapt it for the new context. Return only valid JSON with no markdown formatting.""" # Prepare character data for LLM char_summary = json.dumps(character.data, indent=2) llm_prompt = f"""Convert this character profile to a {target_type} profile: Original character name: {character.name} Target {target_type} name: {new_name} Character data: {char_summary} Create a new {target_type} JSON structure appropriate for {target_type}s.""" llm_response = call_llm(llm_prompt, system_prompt) # Clean response clean_json = llm_response.replace('```json', '').replace('```', '').strip() new_data = json.loads(clean_json) # Ensure required fields new_data[f'{target_type}_id'] = safe_slug new_data[f'{target_type}_name'] = new_name except Exception as e: logger.exception("LLM transfer error: %s", e) flash(f'Failed to generate {target_type} with AI: {e}') return redirect(url_for('transfer_character', slug=slug)) else: # Create blank template for target type new_data = { f'{target_type}_id': safe_slug, f'{target_type}_name': new_name, 'description': f'Transferred from character: {character.name}', 'tags': character.data.get('tags', []), 'lora': character.data.get('lora', {}) } try: # Save new file file_path = os.path.join(target_dir, f"{safe_slug}.json") with open(file_path, 'w') as f: json.dump(new_data, f, indent=2) # Create database entry new_entity = model_class( **{id_field: safe_slug}, slug=safe_slug, filename=f"{safe_slug}.json", name=new_name, data=new_data ) db.session.add(new_entity) db.session.commit() flash(f'Successfully transferred to {target_type}: {new_name}') # Redirect to new entity's detail page if target_type == 'look': return redirect(url_for('look_detail', slug=safe_slug)) elif target_type == 'outfit': return redirect(url_for('outfit_detail', slug=safe_slug)) elif target_type == 'action': return redirect(url_for('action_detail', slug=safe_slug)) elif target_type == 'style': return redirect(url_for('style_detail', slug=safe_slug)) elif target_type == 'scene': return redirect(url_for('scene_detail', slug=safe_slug)) elif target_type == 'detailer': return redirect(url_for('detailer_detail', slug=safe_slug)) except Exception as e: logger.exception("Transfer save error: %s", e) flash(f'Failed to save transferred {target_type}: {e}') return redirect(url_for('transfer_character', slug=slug)) # GET request - show transfer form return render_template('transfer.html', character=character) @app.route('/create', methods=['GET', 'POST']) def create_character(): # Form data to preserve on errors form_data = {} if request.method == 'POST': 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') # Store form data for re-rendering on error form_data = { 'name': name, 'filename': slug, 'prompt': prompt, 'wiki_url': wiki_url, 'use_llm': use_llm, 'outfit_mode': outfit_mode, 'existing_outfit_id': existing_outfit_id } # Check for AJAX request is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' # Auto-generate slug from name if not provided if not slug: slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') # Validate slug safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) if not safe_slug: safe_slug = 'character' # Find available filename (increment if exists) base_slug = safe_slug counter = 1 while os.path.exists(os.path.join(app.config['CHARACTERS_DIR'], f"{safe_slug}.json")): safe_slug = f"{base_slug}_{counter}" counter += 1 # Check if LLM generation is requested if use_llm: if not prompt: error_msg = "Description is required when AI generation is enabled." if is_ajax: return jsonify({'error': error_msg}), 400 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("Fetching character data from URL: %s", 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("Got wiki reference data (%d chars)", len(wiki_data)) else: logger.warning("Failed to fetch wiki data from %s", wiki_url) # Step 1: Generate or select outfit first default_outfit_id = 'default' generated_outfit = None if outfit_mode == 'generate': # Generate outfit with LLM outfit_slug = f"{safe_slug}_outfit" outfit_name = f"{name} - default" outfit_prompt = f"""Generate an outfit for character "{name}". The character is described as: {prompt}{wiki_reference} Create an outfit JSON with wardrobe fields appropriate for this character.""" system_prompt = load_prompt('outfit_system.txt') if not system_prompt: error_msg = "Outfit system prompt file not found." if is_ajax: return jsonify({'error': error_msg}), 500 flash(error_msg) return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) try: outfit_response = call_llm(outfit_prompt, system_prompt) clean_outfit_json = outfit_response.replace('```json', '').replace('```', '').strip() outfit_data = json.loads(clean_outfit_json) # Enforce outfit IDs outfit_data['outfit_id'] = outfit_slug outfit_data['outfit_name'] = outfit_name # Ensure required fields if 'wardrobe' not in outfit_data: outfit_data['wardrobe'] = { "base": "", "head": "", "upper_body": "", "lower_body": "", "hands": "", "feet": "", "additional": "" } if 'lora' not in outfit_data: outfit_data['lora'] = { "lora_name": "", "lora_weight": 0.8, "lora_triggers": "" } if 'tags' not in outfit_data: outfit_data['tags'] = [] # Save the outfit outfit_path = os.path.join(app.config['CLOTHING_DIR'], f"{outfit_slug}.json") with open(outfit_path, 'w') as f: json.dump(outfit_data, f, indent=2) # Create DB entry generated_outfit = Outfit( outfit_id=outfit_slug, slug=outfit_slug, name=outfit_name, data=outfit_data ) db.session.add(generated_outfit) db.session.commit() default_outfit_id = outfit_slug logger.info("Generated outfit: %s for character %s", outfit_name, name) except Exception as e: logger.exception("Outfit generation error: %s", e) # Fall back to default default_outfit_id = 'default' elif outfit_mode == 'existing' and existing_outfit_id: # Use selected existing outfit default_outfit_id = existing_outfit_id else: # Use default outfit default_outfit_id = 'default' # Step 2: Generate character (without wardrobe section) char_prompt = f"""Generate a character named "{name}". Description: {prompt}{wiki_reference} Default Outfit: {default_outfit_id} Create a character JSON with identity, styles, and defaults sections. Do NOT include a wardrobe section - the outfit is handled separately.""" system_prompt = load_prompt('character_system.txt') if not system_prompt: error_msg = "Character system prompt file not found." if is_ajax: return jsonify({'error': error_msg}), 500 flash(error_msg) return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) try: llm_response = call_llm(char_prompt, system_prompt) # Clean response (remove markdown if present) clean_json = llm_response.replace('```json', '').replace('```', '').strip() char_data = json.loads(clean_json) # Enforce IDs char_data['character_id'] = safe_slug char_data['character_name'] = name # Ensure outfit reference is set if 'defaults' not in char_data: char_data['defaults'] = {} char_data['defaults']['outfit'] = default_outfit_id # Remove any wardrobe section that LLM might have added char_data.pop('wardrobe', None) except Exception as e: logger.exception("LLM error: %s", e) error_msg = f"Failed to generate character profile: {e}" if is_ajax: return jsonify({'error': error_msg}), 500 flash(error_msg) return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) else: # Non-LLM: Create minimal character template char_data = { "character_id": safe_slug, "character_name": name, "identity": { "base": prompt, "head": "", "upper_body": "", "lower_body": "", "hands": "", "feet": "", "additional": "" }, "defaults": { "expression": "", "pose": "", "scene": "", "outfit": existing_outfit_id if outfit_mode == 'existing' else 'default' }, "styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" }, "lora": { "lora_name": "", "lora_weight": 1, "lora_weight_min": 0.7, "lora_weight_max": 1, "lora_triggers": "" }, "tags": [] } try: # Save file file_path = os.path.join(app.config['CHARACTERS_DIR'], f"{safe_slug}.json") with open(file_path, 'w') as f: json.dump(char_data, f, indent=2) # Add to DB new_char = Character( character_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", name=name, data=char_data ) # If outfit was generated, assign it to the character if outfit_mode == 'generate' and default_outfit_id != 'default': new_char.assigned_outfit_ids = [default_outfit_id] db.session.commit() db.session.add(new_char) db.session.commit() flash('Character created successfully!') return redirect(url_for('detail', slug=safe_slug)) except Exception as e: logger.exception("Save error: %s", e) error_msg = f"Failed to create character: {e}" if is_ajax: return jsonify({'error': error_msg}), 500 flash(error_msg) return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) # GET request: show all outfits for the selector all_outfits = Outfit.query.order_by(Outfit.name).all() return render_template('create.html', form_data={}, all_outfits=all_outfits) @app.route('/character//edit', methods=['GET', 'POST']) def edit_character(slug): character = Character.query.filter_by(slug=slug).first_or_404() loras = get_available_loras('characters') char_looks = Look.query.filter_by(character_id=character.character_id).order_by(Look.name).all() if request.method == 'POST': try: # 1. Update basic fields character.name = request.form.get('character_name') # 2. Rebuild the data dictionary new_data = character.data.copy() new_data['character_name'] = character.name # Update nested sections (non-wardrobe) for section in ['identity', 'defaults', 'styles', 'lora']: if section in new_data: for key in new_data[section]: form_key = f"{section}_{key}" if form_key in request.form: val = request.form.get(form_key) # Handle numeric weight if key == 'lora_weight': try: val = float(val) except: val = 1.0 new_data[section][key] = val # LoRA weight randomization bounds (new fields not present in existing JSON) for bound in ['lora_weight_min', 'lora_weight_max']: val_str = request.form.get(f'lora_{bound}', '').strip() if val_str: try: new_data.setdefault('lora', {})[bound] = float(val_str) except ValueError: pass else: new_data.setdefault('lora', {}).pop(bound, None) # Handle wardrobe - support both nested and flat formats wardrobe = new_data.get('wardrobe', {}) if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): # New nested format - update each outfit for outfit_name in wardrobe.keys(): for key in wardrobe[outfit_name].keys(): form_key = f"wardrobe_{outfit_name}_{key}" if form_key in request.form: wardrobe[outfit_name][key] = request.form.get(form_key) new_data['wardrobe'] = wardrobe else: # Legacy flat format if 'wardrobe' in new_data: for key in new_data['wardrobe'].keys(): form_key = f"wardrobe_{key}" if form_key in request.form: new_data['wardrobe'][key] = request.form.get(form_key) # 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") # 3. Write back to JSON file # Use the filename we stored during sync, or fallback to a sanitized ID char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) with open(file_path, 'w') as f: json.dump(new_data, f, indent=2) db.session.commit() flash('Character profile updated successfully!') return redirect(url_for('detail', slug=slug)) except Exception as e: logger.exception("Edit error: %s", e) flash(f"Error saving changes: {str(e)}") return render_template('edit.html', character=character, loras=loras, char_looks=char_looks) @app.route('/character//outfit/switch', methods=['POST']) def switch_outfit(slug): """Switch the active outfit for a character.""" character = Character.query.filter_by(slug=slug).first_or_404() outfit_id = request.form.get('outfit', 'default') # Get available outfits and validate available_outfits = character.get_available_outfits() available_ids = [o['outfit_id'] for o in available_outfits] if outfit_id in available_ids: character.active_outfit = outfit_id db.session.commit() outfit_name = next((o['name'] for o in available_outfits if o['outfit_id'] == outfit_id), outfit_id) flash(f'Switched to "{outfit_name}" outfit.') else: flash(f'Outfit "{outfit_id}" not found.', 'error') return redirect(url_for('detail', slug=slug)) @app.route('/character//outfit/assign', methods=['POST']) def assign_outfit(slug): """Assign an outfit from the Outfit table to this character.""" character = Character.query.filter_by(slug=slug).first_or_404() outfit_id = request.form.get('outfit_id') if not outfit_id: flash('No outfit selected.', 'error') return redirect(url_for('detail', slug=slug)) # Check if outfit exists outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() if not outfit: flash(f'Outfit "{outfit_id}" not found.', 'error') return redirect(url_for('detail', slug=slug)) # Assign outfit if character.assign_outfit(outfit_id): db.session.commit() flash(f'Assigned outfit "{outfit.name}" to {character.name}.') else: flash(f'Outfit "{outfit.name}" is already assigned.', 'warning') return redirect(url_for('detail', slug=slug)) @app.route('/character//outfit/unassign/', methods=['POST']) def unassign_outfit(slug, outfit_id): """Unassign an outfit from this character.""" character = Character.query.filter_by(slug=slug).first_or_404() if character.unassign_outfit(outfit_id): db.session.commit() flash('Outfit unassigned.') else: flash('Outfit was not assigned.', 'warning') return redirect(url_for('detail', slug=slug)) @app.route('/character//outfit/add', methods=['POST']) def add_outfit(slug): """Add a new outfit to a character.""" character = Character.query.filter_by(slug=slug).first_or_404() outfit_name = request.form.get('outfit_name', '').strip() if not outfit_name: flash('Outfit name cannot be empty.', 'error') return redirect(url_for('edit_character', slug=slug)) # Sanitize outfit name for use as key safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', outfit_name.lower()) # Get wardrobe data wardrobe = character.data.get('wardrobe', {}) # Ensure wardrobe is in new nested format if 'default' not in wardrobe or not isinstance(wardrobe.get('default'), dict): # Convert legacy format wardrobe = {'default': wardrobe} # Check if outfit already exists if safe_name in wardrobe: flash(f'Outfit "{safe_name}" already exists.', 'error') return redirect(url_for('edit_character', slug=slug)) # Create new outfit (copy from default as template) default_outfit = wardrobe.get('default', { 'base': '', 'head': '', 'upper_body': '', 'lower_body': '', 'hands': '', 'feet': '', 'additional': '' }) wardrobe[safe_name] = default_outfit.copy() # Update character data character.data['wardrobe'] = wardrobe flag_modified(character, 'data') # Save to JSON file char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) with open(file_path, 'w') as f: json.dump(character.data, f, indent=2) db.session.commit() flash(f'Added new outfit "{safe_name}".') return redirect(url_for('edit_character', slug=slug)) @app.route('/character//outfit/delete', methods=['POST']) def delete_outfit(slug): """Delete an outfit from a character.""" character = Character.query.filter_by(slug=slug).first_or_404() outfit_name = request.form.get('outfit', '') wardrobe = character.data.get('wardrobe', {}) # Cannot delete default if outfit_name == 'default': flash('Cannot delete the default outfit.', 'error') return redirect(url_for('edit_character', slug=slug)) if outfit_name not in wardrobe: flash(f'Outfit "{outfit_name}" not found.', 'error') return redirect(url_for('edit_character', slug=slug)) # Delete outfit del wardrobe[outfit_name] character.data['wardrobe'] = wardrobe flag_modified(character, 'data') # Switch active outfit if deleted was active if character.active_outfit == outfit_name: character.active_outfit = 'default' # Save to JSON file char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) with open(file_path, 'w') as f: json.dump(character.data, f, indent=2) db.session.commit() flash(f'Deleted outfit "{outfit_name}".') return redirect(url_for('edit_character', slug=slug)) @app.route('/character//outfit/rename', methods=['POST']) def rename_outfit(slug): """Rename an outfit.""" character = Character.query.filter_by(slug=slug).first_or_404() old_name = request.form.get('old_name', '') new_name = request.form.get('new_name', '').strip() if not new_name: flash('New name cannot be empty.', 'error') return redirect(url_for('edit_character', slug=slug)) # Sanitize new name safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', new_name.lower()) wardrobe = character.data.get('wardrobe', {}) if old_name not in wardrobe: flash(f'Outfit "{old_name}" not found.', 'error') return redirect(url_for('edit_character', slug=slug)) if safe_name in wardrobe and safe_name != old_name: flash(f'Outfit "{safe_name}" already exists.', 'error') return redirect(url_for('edit_character', slug=slug)) # Rename (copy to new key, delete old) wardrobe[safe_name] = wardrobe.pop(old_name) character.data['wardrobe'] = wardrobe flag_modified(character, 'data') # Update active outfit if renamed was active if character.active_outfit == old_name: character.active_outfit = safe_name # Save to JSON file char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) with open(file_path, 'w') as f: json.dump(character.data, f, indent=2) db.session.commit() flash(f'Renamed outfit "{old_name}" to "{safe_name}".') return redirect(url_for('edit_character', slug=slug)) @app.route('/generate_missing', methods=['POST']) def generate_missing(): missing = Character.query.filter( (Character.image_path == None) | (Character.image_path == '') ).order_by(Character.name).all() if not missing: flash("No characters missing cover images.") return redirect(url_for('index')) enqueued = 0 for character in missing: try: with open('comfy_workflow.json', 'r') as f: workflow = json.load(f) prompts = build_prompt(character.data, None, character.default_fields, character.active_outfit) ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) _slug = character.slug _enqueue_job(f"{character.name} – cover", workflow, _make_finalize('characters', _slug, Character)) enqueued += 1 except Exception as e: logger.exception("Error queuing cover generation for %s: %s", character.name, e) flash(f"Queued {enqueued} cover image generation job{'s' if enqueued != 1 else ''}.") return redirect(url_for('index')) @app.route('/character//generate', methods=['POST']) def generate_image(slug): character = Character.query.filter_by(slug=slug).first_or_404() try: # Get action type action = request.form.get('action', 'preview') # Get selected fields selected_fields = request.form.getlist('include_field') # Get additional prompts extra_positive = request.form.get('extra_positive', '').strip() extra_negative = request.form.get('extra_negative', '').strip() # Save preferences session[f'prefs_{slug}'] = selected_fields session[f'extra_pos_{slug}'] = extra_positive session[f'extra_neg_{slug}'] = extra_negative session.modified = True # Parse optional seed seed_val = request.form.get('seed', '').strip() fixed_seed = int(seed_val) if seed_val else None # Build workflow with open('comfy_workflow.json', 'r') as f: workflow = json.load(f) prompts = build_prompt(character.data, selected_fields, character.default_fields, character.active_outfit) if extra_positive: prompts["main"] = f"{prompts['main']}, {extra_positive}" ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed) label = f"{character.name} – {action}" job = _enqueue_job(label, workflow, _make_finalize('characters', slug, Character, action)) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('detail', slug=slug)) except Exception as e: logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('detail', slug=slug))