Major refactor: deduplicate routes, sync, JS, and fix bugs

- Extract 8 common route patterns into factory functions in routes/shared.py
  (favourite, upload, replace cover, save defaults, clone, save JSON,
  get missing, clear covers) — removes ~1,100 lines across 9 route files
- Extract generic _sync_category() in sync.py — 7 sync functions become
  one-liner wrappers, removing ~350 lines
- Extract shared detail page JS into static/js/detail-common.js — all 9
  detail templates now call initDetailPage() with minimal config
- Extract layout inline JS into static/js/layout-utils.js (~185 lines)
- Extract library toolbar JS into static/js/library-toolbar.js
- Fix finalize missing-image bug: raise RuntimeError instead of logging
  warning so job is marked failed
- Fix missing scheduler default in _default_checkpoint_data()
- Fix N+1 query in Character.get_available_outfits() with batch IN query
- Convert all print() to logger across services and routes
- Add missing tags display to styles, scenes, detailers, checkpoints detail
- Update delete buttons to use trash.png icon with solid red background
- Update CLAUDE.md to reflect new architecture

Net reduction: ~1,600 lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-21 23:06:58 +00:00
parent ed9a7b4b11
commit 55ff58aba6
42 changed files with 1493 additions and 3105 deletions

View File

@@ -24,12 +24,13 @@ services/
prompts.py # Prompt building + dedup (build_prompt, build_extras_prompt)
llm.py # LLM integration + MCP tool calls (call_llm, load_prompt)
mcp.py # MCP/Docker server lifecycle (ensure_mcp_server_running)
sync.py # All sync_*() functions + preset resolution helpers
sync.py # Generic _sync_category() + sync_characters/sync_checkpoints + preset resolution helpers
job_queue.py # Background job queue (_enqueue_job, _make_finalize, worker thread)
file_io.py # LoRA/checkpoint scanning, file helpers
generation.py # Shared generation logic (generate_from_preset)
routes/
__init__.py # register_routes(app) — imports and calls all route modules
shared.py # Factory functions for common routes (favourite, upload, clone, save_json, etc.)
characters.py # Character CRUD + generation + outfit management
outfits.py # Outfit routes
actions.py # Action routes
@@ -48,6 +49,10 @@ routes/
api.py # REST API v1 (preset generation, auth)
regenerate.py # Tag regeneration (single + bulk, via LLM queue)
search.py # Global search across resources and gallery images
static/js/
detail-common.js # Shared detail page JS (initDetailPage: preview, generation, batch, endless, JSON editor)
layout-utils.js # Extracted from layout.html (confirmResourceDelete, regenerateTags, initJsonEditor)
library-toolbar.js # Library page toolbar (batch generate, clear covers, missing items)
```
### Dependency Graph
@@ -67,16 +72,19 @@ app.py
│ ├── file_io.py ← models, utils
│ └── generation.py ← prompts, workflow, job_queue, sync, models
└── routes/
├── All route modules ← services/*, utils, models
── (routes never import from other routes)
├── shared.py ← models, utils, services (lazy-init to avoid circular imports)
── All route modules ← services/*, utils, models, shared
└── (routes never import from other routes except shared.py)
```
**No circular imports**: routes → services → utils/models. Services never import routes. Utils never imports services.
**No circular imports**: routes → services → utils/models. Services never import routes. Utils never imports services. `routes/shared.py` uses `_init_models()` lazy initialization to avoid circular imports with model classes.
### Route Registration Pattern
Routes use a `register_routes(app)` closure pattern — each route module defines a function that receives the Flask `app` object and registers routes via `@app.route()` closures. This preserves all existing `url_for()` endpoint names without requiring Blueprint prefixes. Helper functions used only by routes in that module are defined inside `register_routes()` before the routes that reference them.
Common routes (favourite, upload, replace cover, save defaults, clone, save JSON, get missing, clear covers) are registered via factory functions in `routes/shared.py`. Each route module calls `register_common_routes(app, 'category_name')` as the first line of its `register_routes()`. The `CATEGORY_CONFIG` dict in `shared.py` maps each category to its model, URL prefix, endpoint names, and directory paths.
### Database
SQLite at `instance/database.db`, managed by Flask-SQLAlchemy. The DB is a cache of the JSON files on disk — the JSON files are the source of truth.
@@ -186,8 +194,9 @@ Two independent queues with separate worker threads:
### `services/sync.py` — Data Synchronization
- **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category.
- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called in every sync function on both create and update paths.
- **`_sync_category(config_key, model_class, id_field, name_field, extra_fn=None, sync_nsfw=True)`** — Generic sync function handling 80% shared logic. 7 sync functions are one-liner wrappers.
- **`sync_characters()`** / **`sync_checkpoints()`** — Custom sync functions (special ID handling, filesystem scan).
- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called by `_sync_category` and custom sync functions.
- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers.
### `services/file_io.py` — File & DB Helpers
@@ -424,7 +433,10 @@ Image retrieval is handled server-side by the `_make_finalize()` callback; there
- Navbar with links to all sections
- Global default checkpoint selector (saves to session via AJAX)
- Resource delete modal (soft/hard) shared across gallery pages
- `initJsonEditor(saveUrl)` — shared JSON editor modal (simple form + raw textarea tabs)
- **Shared JS files** (extracted from inline scripts):
- `static/js/detail-common.js``initDetailPage(options)`: favourite toggle, selectPreview, waitForJob, AJAX form submit + polling, addToPreviewGallery, batch generation, endless mode, JSON editor init. All 9 detail templates call this with minimal config.
- `static/js/layout-utils.js``confirmResourceDelete(mode)`, `regenerateTags(category, slug)`, `initJsonEditor(saveUrl)`
- `static/js/library-toolbar.js` — Library page toolbar (batch generate, clear covers, missing items)
- Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template. The `random_gen_image(category, slug)` template global returns a random image path from `static/uploads/<category>/<slug>/` for use as a fallback cover when `image_path` is not set.
- **No `{% block head %}` exists** in layout.html — do not try to use it.
- Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `{"job_id": ...}` response, then polls `/api/queue/<job_id>/status` every ~1.5 seconds until `status == "done"`. The server-side worker handles all ComfyUI polling and image saving via the `_make_finalize()` callback. There are no client-facing finalize HTTP routes.
@@ -522,14 +534,15 @@ Absolute paths on disk:
To add a new content category (e.g. "Poses" as a separate concept from Actions), the pattern is:
1. **Model** (`models.py`): Add a new SQLAlchemy model with the standard fields.
2. **Sync function** (`services/sync.py`): Add `sync_newcategory()` following the pattern of `sync_outfits()`.
2. **Sync function** (`services/sync.py`): Add `sync_newcategory()` as a one-liner using `_sync_category()` (or custom if needed).
3. **Data directory** (`app.py`): Add `app.config['NEWCATEGORY_DIR'] = 'data/newcategory'`.
4. **Routes** (`routes/newcategory.py`): Create a new route module with a `register_routes(app)` function. Implement index, detail, edit, generate, replace_cover_from_preview, upload, save_defaults, clone, rescan routes. Follow `routes/outfits.py` or `routes/scenes.py` exactly.
5. **Route registration** (`routes/__init__.py`): Import and call `newcategory.register_routes(app)`.
6. **Templates**: Create `templates/newcategory/{index,detail,edit,create}.html` extending `layout.html`.
7. **Nav**: Add link to navbar in `templates/layout.html`.
8. **Startup** (`app.py`): Import and call `sync_newcategory()` in the `with app.app_context()` block.
9. **Generator page**: Add to `routes/generator.py`, `services/prompts.py` `build_extras_prompt()`, and `templates/generator.html` accordion.
4. **Shared routes** (`routes/shared.py`): Add entry to `CATEGORY_CONFIG` dict with model, url_prefix, detail_endpoint, config_dir, category_folder, id_field, name_field, endpoint_prefix.
5. **Routes** (`routes/newcategory.py`): Create a new route module with a `register_routes(app)` function. Call `register_common_routes(app, 'newcategory')` first, then implement category-specific routes (index, detail, edit, generate, rescan). Follow `routes/outfits.py` or `routes/scenes.py` exactly.
6. **Route registration** (`routes/__init__.py`): Import and call `newcategory.register_routes(app)`.
7. **Templates**: Create `templates/newcategory/{index,detail,edit,create}.html` extending `layout.html`. Detail template should call `initDetailPage({...})` from `detail-common.js`.
8. **Nav**: Add link to navbar in `templates/layout.html`.
9. **Startup** (`app.py`): Import and call `sync_newcategory()` in the `with app.app_context()` block.
10. **Generator page**: Add to `routes/generator.py`, `services/prompts.py` `build_extras_prompt()`, and `templates/generator.html` accordion.
---

122
app.py
View File

@@ -66,38 +66,33 @@ if __name__ == '__main__':
ensure_character_mcp_server_running()
init_queue_worker(app)
with app.app_context():
from sqlalchemy import text
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
db.create_all()
# Migration: Add active_outfit column if it doesn't exist
try:
from sqlalchemy import text
db.session.execute(text('ALTER TABLE character ADD COLUMN active_outfit VARCHAR(100) DEFAULT \'default\''))
db.session.commit()
print("Added active_outfit column to character table")
except Exception as e:
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
print("active_outfit column already exists")
else:
print(f"Migration note: {e}")
# --- Helper for safe column additions ---
def _add_column(table, column, col_type):
try:
db.session.execute(text(f'ALTER TABLE {table} ADD COLUMN {column} {col_type}'))
db.session.commit()
logger.info("Added %s.%s column", table, column)
except Exception as e:
db.session.rollback()
if 'duplicate column name' not in str(e).lower() and 'already exists' not in str(e).lower():
logger.debug("Migration note (%s.%s): %s", table, column, e)
# Migration: Add default_fields column to action table if it doesn't exist
try:
from sqlalchemy import text
db.session.execute(text('ALTER TABLE action ADD COLUMN default_fields JSON'))
db.session.commit()
print("Added default_fields column to action table")
except Exception as e:
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
print("default_fields column already exists in action table")
else:
print(f"Migration action note: {e}")
# --- All migrations (grouped before syncs) ---
_add_column('character', 'active_outfit', "VARCHAR(100) DEFAULT 'default'")
_add_column('action', 'default_fields', 'JSON')
_add_column('checkpoint', 'data', 'JSON')
_add_column('look', 'character_ids', 'JSON')
# Migration: Add new columns to settings table
columns_to_add = [
('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"),
('local_base_url', "VARCHAR(255)"),
('local_model', "VARCHAR(100)"),
# Settings columns
for col_name, col_type in [
('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"),
('local_base_url', 'VARCHAR(255)'),
('local_model', 'VARCHAR(100)'),
('lora_dir_characters', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Looks'"),
('lora_dir_outfits', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Clothing'"),
('lora_dir_actions', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Poses'"),
@@ -105,63 +100,33 @@ if __name__ == '__main__':
('lora_dir_scenes', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Backgrounds'"),
('lora_dir_detailers', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Detailers'"),
('checkpoint_dirs', "VARCHAR(1000) DEFAULT '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'"),
('default_checkpoint', "VARCHAR(500)"),
('api_key', "VARCHAR(255)"),
]
for col_name, col_type in columns_to_add:
try:
db.session.execute(text(f'ALTER TABLE settings ADD COLUMN {col_name} {col_type}'))
db.session.commit()
print(f"Added {col_name} column to settings table")
except Exception as e:
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
pass
else:
print(f"Migration settings note ({col_name}): {e}")
('default_checkpoint', 'VARCHAR(500)'),
('api_key', 'VARCHAR(255)'),
]:
_add_column('settings', col_name, col_type)
# Migration: Add is_favourite and is_nsfw columns to all resource tables
_tag_tables = ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint']
for _tbl in _tag_tables:
for _col, _type in [('is_favourite', 'BOOLEAN DEFAULT 0'), ('is_nsfw', 'BOOLEAN DEFAULT 0')]:
try:
db.session.execute(text(f'ALTER TABLE {_tbl} ADD COLUMN {_col} {_type}'))
db.session.commit()
print(f"Added {_col} column to {_tbl} table")
except Exception as e:
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
pass
else:
print(f"Migration note ({_tbl}.{_col}): {e}")
# is_favourite / is_nsfw on all resource tables
for tbl in ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint']:
_add_column(tbl, 'is_favourite', 'BOOLEAN DEFAULT 0')
_add_column(tbl, 'is_nsfw', 'BOOLEAN DEFAULT 0')
# Ensure settings exist
if not Settings.query.first():
db.session.add(Settings())
db.session.commit()
print("Created default settings")
logger.info("Created default settings")
# Log the default checkpoint on startup
# Log default checkpoint
settings = Settings.query.first()
if settings and settings.default_checkpoint:
logger.info("=" * 80)
logger.info("DEFAULT CHECKPOINT loaded from database: %s", settings.default_checkpoint)
logger.info("=" * 80)
logger.info("Default checkpoint: %s", settings.default_checkpoint)
else:
logger.info("No default checkpoint set in database")
# --- Sync all categories ---
sync_characters()
sync_outfits()
sync_actions()
# Migration: Add data column to checkpoint table
try:
db.session.execute(text('ALTER TABLE checkpoint ADD COLUMN data JSON'))
db.session.commit()
print("Added data column to checkpoint table")
except Exception as e:
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
print("data column already exists in checkpoint table")
else:
print(f"Migration checkpoint note: {e}")
sync_styles()
sync_detailers()
sync_scenes()
@@ -169,20 +134,7 @@ if __name__ == '__main__':
sync_checkpoints()
sync_presets()
# Migration: Convert look.character_id to look.character_ids
try:
from sqlalchemy import text
# First ensure the column exists
db.session.execute(text("ALTER TABLE look ADD COLUMN character_ids JSON"))
db.session.commit()
print("Added character_ids column to look table")
except Exception as e:
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
pass # Column already exists
else:
print(f"Migration note (character_ids column): {e}")
# Migrate existing character_id to character_ids list
# --- Post-sync data migration: character_id character_ids ---
try:
looks_with_old_field = Look.query.filter(Look.character_id.isnot(None)).all()
migrated_count = 0
@@ -194,8 +146,8 @@ if __name__ == '__main__':
migrated_count += 1
if migrated_count > 0:
db.session.commit()
print(f"Migrated {migrated_count} looks from character_id to character_ids")
logger.info("Migrated %d looks from character_id to character_ids", migrated_count)
except Exception as e:
print(f"Migration note (character_ids data): {e}")
logger.debug("Migration note (character_ids data): %s", e)
app.run(debug=True, host='0.0.0.0', port=5000)

View File

@@ -1,6 +1,6 @@
services:
danbooru-mcp:
build: https://git.liveaodh.com/aodhan/danbooru-mcp.git
build: ./tools/danbooru-mcp
image: danbooru-mcp:latest
stdin_open: true
restart: unless-stopped

View File

@@ -65,8 +65,10 @@ class Character(db.Model):
# Add assigned outfits from Outfit table
if self.assigned_outfit_ids:
assigned = Outfit.query.filter(Outfit.outfit_id.in_(self.assigned_outfit_ids)).all()
outfit_by_id = {o.outfit_id: o for o in assigned}
for outfit_id in self.assigned_outfit_ids:
outfit = Outfit.query.filter_by(outfit_id=outfit_id).first()
outfit = outfit_by_id.get(outfit_id)
if outfit:
outfits.append({
'outfit_id': outfit.outfit_id,

View File

@@ -16,24 +16,13 @@ from services.sync import sync_actions
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_actions')
def get_missing_actions():
missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.name).all()
return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]}
@app.route('/clear_all_action_covers', methods=['POST'])
def clear_all_action_covers():
actions = Action.query.all()
for action in actions:
action.image_path = None
db.session.commit()
return {'success': True}
register_common_routes(app, 'actions')
@app.route('/actions')
def actions_index():
@@ -151,40 +140,11 @@ def register_routes(app):
return redirect(url_for('action_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('actions/edit.html', action=action, loras=loras)
@app.route('/action/<path:slug>/upload', methods=['POST'])
def upload_action_image(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
# Create action subfolder
action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}")
os.makedirs(action_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(action_folder, filename)
file.save(file_path)
# Store relative path in DB
action.image_path = f"actions/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/generate', methods=['POST'])
def generate_action_image(slug):
action_obj = Action.query.filter_by(slug=slug).first_or_404()
@@ -352,7 +312,7 @@ def register_routes(app):
# Append to main prompt
if extra_parts:
prompts["main"] += ", " + ", ".join(extra_parts)
print(f"Added extra character: {extra_char.name}")
logger.debug("Added extra character: %s", extra_char.name)
_append_background(prompts, character)
@@ -377,33 +337,12 @@ def register_routes(app):
return redirect(url_for('action_detail', slug=slug))
except Exception as e:
print(f"Generation error: {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('action_detail', slug=slug))
@app.route('/action/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_action_cover_from_preview(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
action.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('action_detail', slug=slug))
@app.route('/action/<path:slug>/save_defaults', methods=['POST'])
def save_action_defaults(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
action.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this action!')
return redirect(url_for('action_detail', slug=slug))
@app.route('/actions/bulk_create', methods=['POST'])
def bulk_create_actions_from_loras():
_s = Settings.query.first()
@@ -556,7 +495,7 @@ def register_routes(app):
action_data['action_id'] = safe_slug
action_data['action_name'] = name
except Exception as e:
print(f"LLM error: {e}")
logger.exception("LLM error: %s", e)
flash(f"Failed to generate action profile: {e}")
return render_template('actions/create.html', form_data=form_data)
else:
@@ -587,74 +526,9 @@ def register_routes(app):
flash('Action created successfully!')
return redirect(url_for('action_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create action: {e}")
return render_template('actions/create.html', form_data=form_data)
return render_template('actions/create.html', form_data=form_data)
@app.route('/action/<path:slug>/clone', methods=['POST'])
def clone_action(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
# Find the next available number for the clone
base_id = action.action_id
match = re.match(r'^(.+?)_(\d+)$', base_id)
if match:
base_name = match.group(1)
current_num = int(match.group(2))
else:
base_name = base_id
current_num = 1
next_num = current_num + 1
while True:
new_id = f"{base_name}_{next_num:02d}"
new_filename = f"{new_id}.json"
new_path = os.path.join(app.config['ACTIONS_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = action.data.copy()
new_data['action_id'] = new_id
new_data['action_name'] = f"{action.name} (Copy)"
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_action = Action(
action_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['action_name'], data=new_data
)
db.session.add(new_action)
db.session.commit()
flash(f'Action cloned as "{new_id}"!')
return redirect(url_for('action_detail', slug=new_slug))
@app.route('/action/<path:slug>/save_json', methods=['POST'])
def save_action_json(slug):
action = Action.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
action.data = new_data
flag_modified(action, 'data')
db.session.commit()
if action.filename:
file_path = os.path.join(app.config['ACTIONS_DIR'], action.filename)
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

@@ -5,8 +5,6 @@ import re
from flask import flash, jsonify, redirect, render_template, request, session, url_for
from sqlalchemy.orm.attributes import flag_modified
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
@@ -14,12 +12,13 @@ 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 utils import allowed_file
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'characters')
@app.route('/')
def index():
@@ -165,7 +164,7 @@ def register_routes(app):
new_data[f'{target_type}_name'] = new_name
except Exception as e:
print(f"LLM transfer error: {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:
@@ -212,7 +211,7 @@ def register_routes(app):
return redirect(url_for('detailer_detail', slug=safe_slug))
except Exception as e:
print(f"Transfer save error: {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))
@@ -356,7 +355,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
logger.info(f"Generated outfit: {outfit_name} for character {name}")
except Exception as e:
print(f"Outfit generation error: {e}")
logger.exception("Outfit generation error: %s", e)
# Fall back to default
default_outfit_id = 'default'
@@ -404,7 +403,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
char_data.pop('wardrobe', None)
except Exception as e:
print(f"LLM error: {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
@@ -473,7 +472,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
return redirect(url_for('detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
error_msg = f"Failed to create character: {e}"
if is_ajax:
return jsonify({'error': error_msg}), 500
@@ -565,7 +564,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
return redirect(url_for('detail', slug=slug))
except Exception as e:
print(f"Edit error: {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)
@@ -757,60 +756,6 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
return redirect(url_for('edit_character', slug=slug))
@app.route('/character/<path:slug>/upload', methods=['POST'])
def upload_image(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
# Create character subfolder
char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{slug}")
os.makedirs(char_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(char_folder, filename)
file.save(file_path)
# Store relative path in DB
character.image_path = f"characters/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('detail', slug=slug))
@app.route('/character/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_cover_from_preview(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
character.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('detail', slug=slug))
@app.route('/get_missing_characters')
def get_missing_characters():
missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.name).all()
return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]}
@app.route('/clear_all_covers', methods=['POST'])
def clear_all_covers():
characters = Character.query.all()
for char in characters:
char.image_path = None
db.session.commit()
return {'success': True}
@app.route('/generate_missing', methods=['POST'])
def generate_missing():
missing = Character.query.filter(
@@ -834,7 +779,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
_enqueue_job(f"{character.name} cover", workflow, _make_finalize('characters', _slug, Character))
enqueued += 1
except Exception as e:
print(f"Error queuing cover generation for {character.name}: {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'))
@@ -882,26 +827,9 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
return redirect(url_for('detail', slug=slug))
except Exception as e:
print(f"Generation error: {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))
@app.route('/character/<path:slug>/save_defaults', methods=['POST'])
def save_defaults(slug):
character = Character.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
character.default_fields = selected_fields
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

@@ -15,11 +15,13 @@ from services.sync import sync_checkpoints, _default_checkpoint_data
from services.file_io import get_available_checkpoints
from services.llm import load_prompt, call_llm
from utils import allowed_file
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'checkpoints')
def _build_checkpoint_workflow(ckpt_obj, character=None, fixed_seed=None, extra_positive=None, extra_negative=None):
"""Build and return a prepared ComfyUI workflow dict for a checkpoint generation."""
@@ -95,26 +97,6 @@ def register_routes(app):
existing_previews=existing_previews,
extra_positive=extra_positive, extra_negative=extra_negative)
@app.route('/checkpoint/<path:slug>/upload', methods=['POST'])
def upload_checkpoint_image(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(url_for('checkpoint_detail', slug=slug))
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(url_for('checkpoint_detail', slug=slug))
if file and allowed_file(file.filename):
folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}")
os.makedirs(folder, exist_ok=True)
filename = secure_filename(file.filename)
file.save(os.path.join(folder, filename))
ckpt.image_path = f"checkpoints/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/generate', methods=['POST'])
def generate_checkpoint_image(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
@@ -145,52 +127,12 @@ def register_routes(app):
return {'status': 'queued', 'job_id': job['id']}
return redirect(url_for('checkpoint_detail', slug=slug))
except Exception as e:
print(f"Generation error: {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('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_checkpoint_cover_from_preview(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
ckpt.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('checkpoint_detail', slug=slug))
@app.route('/checkpoint/<path:slug>/save_json', methods=['POST'])
def save_checkpoint_json(slug):
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
ckpt.data = new_data
flag_modified(ckpt, 'data')
db.session.commit()
checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints')
file_path = os.path.join(checkpoints_dir, f'{ckpt.slug}.json')
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/get_missing_checkpoints')
def get_missing_checkpoints():
missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.name).all()
return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]}
@app.route('/clear_all_checkpoint_covers', methods=['POST'])
def clear_all_checkpoint_covers():
for ckpt in Checkpoint.query.all():
ckpt.image_path = None
db.session.commit()
return {'success': True}
@app.route('/checkpoints/bulk_create', methods=['POST'])
def bulk_create_checkpoints():
checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints')
@@ -304,11 +246,3 @@ def register_routes(app):
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

@@ -3,23 +3,24 @@ import os
import re
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from flask import render_template, request, redirect, url_for, flash, session
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look
from models import db, Character, Detailer, Action, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
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
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS
from utils import _WARDROBE_KEYS
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'detailers')
def _queue_detailer_generation(detailer_obj, character=None, selected_fields=None, client_id=None, action=None, extra_positive=None, extra_negative=None, fixed_seed=None):
if character:
@@ -193,40 +194,11 @@ def register_routes(app):
return redirect(url_for('detailer_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('detailers/edit.html', detailer=detailer, loras=loras)
@app.route('/detailer/<path:slug>/upload', methods=['POST'])
def upload_detailer_image(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
# Create detailer subfolder
detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}")
os.makedirs(detailer_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(detailer_folder, filename)
file.save(file_path)
# Store relative path in DB
detailer.image_path = f"detailers/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/generate', methods=['POST'])
def generate_detailer_image(slug):
detailer_obj = Detailer.query.filter_by(slug=slug).first_or_404()
@@ -277,49 +249,12 @@ def register_routes(app):
return redirect(url_for('detailer_detail', slug=slug))
except Exception as e:
print(f"Generation error: {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('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/save_defaults', methods=['POST'])
def save_detailer_defaults(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
detailer.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this detailer!')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_detailer_cover_from_preview(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
detailer.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('detailer_detail', slug=slug))
@app.route('/detailer/<path:slug>/save_json', methods=['POST'])
def save_detailer_json(slug):
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
detailer.data = new_data
flag_modified(detailer, 'data')
db.session.commit()
if detailer.filename:
file_path = os.path.join(app.config['DETAILERS_DIR'], detailer.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/detailers/bulk_create', methods=['POST'])
def bulk_create_detailers_from_loras():
_s = Settings.query.first()
@@ -463,17 +398,9 @@ def register_routes(app):
flash('Detailer created successfully!')
return redirect(url_for('detailer_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create detailer: {e}")
return render_template('detailers/create.html', form_data=form_data)
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

@@ -3,18 +3,17 @@ import os
import re
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from flask import render_template, request, redirect, url_for, flash, session
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
from models import db, Character, Look, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
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.prompts import build_prompt, _resolve_character, _ensure_character_fields, _dedup_tags
from services.sync import sync_looks
from services.file_io import get_available_loras, _count_look_assignments
from services.llm import load_prompt, call_llm
from utils import allowed_file
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
@@ -54,6 +53,7 @@ def _fix_look_lora_data(lora_data):
def register_routes(app):
register_common_routes(app, 'looks')
@app.route('/looks')
def looks_index():
@@ -173,23 +173,6 @@ def register_routes(app):
return render_template('looks/edit.html', look=look, characters=characters, loras=loras)
@app.route('/look/<path:slug>/upload', methods=['POST'])
def upload_look_image(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file selected')
return redirect(url_for('look_detail', slug=slug))
file = request.files['image']
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f'looks/{slug}')
os.makedirs(look_folder, exist_ok=True)
file_path = os.path.join(look_folder, filename)
file.save(file_path)
look.image_path = f'looks/{slug}/{filename}'
db.session.commit()
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/generate', methods=['POST'])
def generate_look_image(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
@@ -275,32 +258,12 @@ def register_routes(app):
return redirect(url_for('look_detail', slug=slug))
except Exception as e:
print(f"Generation error: {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('look_detail', slug=slug))
@app.route('/look/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_look_cover_from_preview(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
look.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/save_defaults', methods=['POST'])
def save_look_defaults(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
look.default_fields = request.form.getlist('include_field')
db.session.commit()
flash('Default prompt selection saved!')
return redirect(url_for('look_detail', slug=slug))
@app.route('/look/<path:slug>/generate_character', methods=['POST'])
def generate_character_from_look(slug):
"""Generate a character JSON using a look as the base."""
@@ -426,23 +389,6 @@ Character ID: {character_slug}"""
flash(f'Character "{character_name}" created from look!', 'success')
return redirect(url_for('detail', slug=character_slug))
@app.route('/look/<path:slug>/save_json', methods=['POST'])
def save_look_json(slug):
look = Look.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
look.data = new_data
look.character_id = new_data.get('character_id', look.character_id)
flag_modified(look, 'data')
db.session.commit()
if look.filename:
file_path = os.path.join(app.config['LOOKS_DIR'], look.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
@app.route('/look/create', methods=['GET', 'POST'])
def create_look():
characters = Character.query.order_by(Character.name).all()
@@ -499,25 +445,12 @@ Character ID: {character_slug}"""
flash(f'Look "{name}" created!')
return redirect(url_for('look_detail', slug=slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", 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, form_data=form_data)
@app.route('/get_missing_looks')
def get_missing_looks():
missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.name).all()
return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]}
@app.route('/clear_all_look_covers', methods=['POST'])
def clear_all_look_covers():
looks = Look.query.all()
for look in looks:
look.image_path = None
db.session.commit()
return {'success': True}
@app.route('/looks/bulk_create', methods=['POST'])
def bulk_create_looks_from_loras():
_s = Settings.query.first()
@@ -615,11 +548,3 @@ Character ID: {character_slug}"""
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
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

@@ -15,24 +15,13 @@ from services.sync import sync_outfits
from services.file_io import get_available_loras, _count_outfit_lora_assignments
from utils import allowed_file, _LORA_DEFAULTS
from services.llm import load_prompt, call_llm
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_outfits')
def get_missing_outfits():
missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.name).all()
return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]}
@app.route('/clear_all_outfit_covers', methods=['POST'])
def clear_all_outfit_covers():
outfits = Outfit.query.all()
for outfit in outfits:
outfit.image_path = None
db.session.commit()
return {'success': True}
register_common_routes(app, 'outfits')
@app.route('/outfits')
def outfits_index():
@@ -268,40 +257,11 @@ def register_routes(app):
return redirect(url_for('outfit_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('outfits/edit.html', outfit=outfit, loras=loras)
@app.route('/outfit/<path:slug>/upload', methods=['POST'])
def upload_outfit_image(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
# Create outfit subfolder
outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}")
os.makedirs(outfit_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(outfit_folder, filename)
file.save(file_path)
# Store relative path in DB
outfit.image_path = f"outfits/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/<path:slug>/generate', methods=['POST'])
def generate_outfit_image(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
@@ -406,24 +366,12 @@ def register_routes(app):
return redirect(url_for('outfit_detail', slug=slug))
except Exception as e:
print(f"Generation error: {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('outfit_detail', slug=slug))
@app.route('/outfit/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_outfit_cover_from_preview(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
outfit.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/create', methods=['GET', 'POST'])
def create_outfit():
form_data = {}
@@ -496,7 +444,7 @@ def register_routes(app):
outfit_data['tags'] = []
except Exception as e:
print(f"LLM error: {e}")
logger.exception("LLM error: %s", e)
flash(f"Failed to generate outfit profile: {e}")
return render_template('outfits/create.html', form_data=form_data)
else:
@@ -542,92 +490,10 @@ def register_routes(app):
return redirect(url_for('outfit_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create outfit: {e}")
return render_template('outfits/create.html', form_data=form_data)
return render_template('outfits/create.html', form_data=form_data)
@app.route('/outfit/<path:slug>/save_defaults', methods=['POST'])
def save_outfit_defaults(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
outfit.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this outfit!')
return redirect(url_for('outfit_detail', slug=slug))
@app.route('/outfit/<path:slug>/clone', methods=['POST'])
def clone_outfit(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
# Find the next available number for the clone
base_id = outfit.outfit_id
# Extract base name without number suffix
import re
match = re.match(r'^(.+?)_(\d+)$', base_id)
if match:
base_name = match.group(1)
current_num = int(match.group(2))
else:
base_name = base_id
current_num = 1
# Find next available number
next_num = current_num + 1
while True:
new_id = f"{base_name}_{next_num:02d}"
new_filename = f"{new_id}.json"
new_path = os.path.join(app.config['CLOTHING_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
# Create new outfit data (copy of original)
new_data = outfit.data.copy()
new_data['outfit_id'] = new_id
new_data['outfit_name'] = f"{outfit.name} (Copy)"
# Save the new JSON file
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
# Create new outfit in database
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_outfit = Outfit(
outfit_id=new_id,
slug=new_slug,
filename=new_filename,
name=new_data['outfit_name'],
data=new_data
)
db.session.add(new_outfit)
db.session.commit()
flash(f'Outfit cloned as "{new_id}"!')
return redirect(url_for('outfit_detail', slug=new_slug))
@app.route('/outfit/<path:slug>/save_json', methods=['POST'])
def save_outfit_json(slug):
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
outfit.data = new_data
flag_modified(outfit, 'data')
db.session.commit()
if outfit.filename:
file_path = os.path.join(app.config['CLOTHING_DIR'], outfit.filename)
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

@@ -2,23 +2,19 @@ import json
import os
import re
import logging
import random
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look, Settings
from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look
from sqlalchemy.orm.attributes import flag_modified
from services.prompts import build_prompt, _dedup_tags, _resolve_character, _ensure_character_fields, _append_background
from services.workflow import _prepare_workflow, _get_default_checkpoint
from services.job_queue import _enqueue_job, _make_finalize
from services.sync import sync_presets, _resolve_preset_entity, _resolve_preset_fields, _PRESET_ENTITY_MAP
from services.sync import sync_presets
from services.generation import generate_from_preset
from services.llm import load_prompt, call_llm
from utils import allowed_file
from routes.shared import register_common_routes
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'presets')
@app.route('/presets')
def presets_index():
@@ -79,37 +75,6 @@ def register_routes(app):
flash(f"Error during generation: {str(e)}")
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_preset_cover_from_preview(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(current_app.config['UPLOAD_FOLDER'], preview_path)):
preset.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/upload', methods=['POST'])
def upload_preset_image(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file uploaded.')
return redirect(url_for('preset_detail', slug=slug))
file = request.files['image']
if file.filename == '':
flash('No file selected.')
return redirect(url_for('preset_detail', slug=slug))
filename = secure_filename(file.filename)
folder = os.path.join(current_app.config['UPLOAD_FOLDER'], f'presets/{slug}')
os.makedirs(folder, exist_ok=True)
file.save(os.path.join(folder, filename))
preset.image_path = f'presets/{slug}/{filename}'
db.session.commit()
flash('Image uploaded!')
return redirect(url_for('preset_detail', slug=slug))
@app.route('/preset/<path:slug>/edit', methods=['GET', 'POST'])
def edit_preset(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
@@ -196,51 +161,6 @@ def register_routes(app):
styles=styles, scenes=scenes, detailers=detailers,
looks=looks, checkpoints=checkpoints)
@app.route('/preset/<path:slug>/save_json', methods=['POST'])
def save_preset_json(slug):
preset = Preset.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
preset.data = new_data
preset.name = new_data.get('preset_name', preset.name)
flag_modified(preset, "data")
db.session.commit()
if preset.filename:
file_path = os.path.join(current_app.config['PRESETS_DIR'], preset.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}, 400
@app.route('/preset/<path:slug>/clone', methods=['POST'])
def clone_preset(slug):
original = Preset.query.filter_by(slug=slug).first_or_404()
new_data = dict(original.data)
base_id = f"{original.preset_id}_copy"
new_id = base_id
counter = 1
while Preset.query.filter_by(preset_id=new_id).first():
new_id = f"{base_id}_{counter}"
counter += 1
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_data['preset_id'] = new_id
new_data['preset_name'] = f"{original.name} (Copy)"
new_filename = f"{new_id}.json"
os.makedirs(current_app.config['PRESETS_DIR'], exist_ok=True)
with open(os.path.join(current_app.config['PRESETS_DIR'], new_filename), 'w') as f:
json.dump(new_data, f, indent=2)
new_preset = Preset(preset_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['preset_name'], data=new_data)
db.session.add(new_preset)
db.session.commit()
flash(f"Cloned as '{new_data['preset_name']}'")
return redirect(url_for('preset_detail', slug=new_slug))
@app.route('/presets/rescan', methods=['POST'])
def rescan_presets():
sync_presets()
@@ -322,7 +242,3 @@ def register_routes(app):
return render_template('presets/create.html', form_data=form_data)
@app.route('/get_missing_presets')
def get_missing_presets():
missing = Preset.query.filter((Preset.image_path == None) | (Preset.image_path == '')).order_by(Preset.filename).all()
return {'missing': [{'slug': p.slug, 'name': p.name} for p in missing]}

View File

@@ -3,36 +3,24 @@ import os
import re
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from flask import render_template, request, redirect, url_for, flash, session
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look
from models import db, Character, Scene, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
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.prompts import build_prompt, _resolve_character, _ensure_character_fields
from services.sync import sync_scenes
from services.file_io import get_available_loras
from services.llm import load_prompt, call_llm
from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS
from routes.shared import register_common_routes
from utils import _WARDROBE_KEYS
logger = logging.getLogger('gaze')
def register_routes(app):
@app.route('/get_missing_scenes')
def get_missing_scenes():
missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.name).all()
return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]}
@app.route('/clear_all_scene_covers', methods=['POST'])
def clear_all_scene_covers():
scenes = Scene.query.all()
for scene in scenes:
scene.image_path = None
db.session.commit()
return {'success': True}
register_common_routes(app, 'scenes')
@app.route('/scenes')
def scenes_index():
@@ -147,40 +135,11 @@ def register_routes(app):
return redirect(url_for('scene_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('scenes/edit.html', scene=scene, loras=loras)
@app.route('/scene/<path:slug>/upload', methods=['POST'])
def upload_scene_image(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
# Create scene subfolder
scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}")
os.makedirs(scene_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(scene_folder, filename)
file.save(file_path)
# Store relative path in DB
scene.image_path = f"scenes/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('scene_detail', slug=slug))
def _queue_scene_generation(scene_obj, character=None, selected_fields=None, client_id=None, fixed_seed=None, extra_positive=None, extra_negative=None):
if character:
combined_data = character.data.copy()
@@ -306,33 +265,12 @@ def register_routes(app):
return redirect(url_for('scene_detail', slug=slug))
except Exception as e:
print(f"Generation error: {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('scene_detail', slug=slug))
@app.route('/scene/<path:slug>/save_defaults', methods=['POST'])
def save_scene_defaults(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
scene.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this scene!')
return redirect(url_for('scene_detail', slug=slug))
@app.route('/scene/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_scene_cover_from_preview(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
scene.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('scene_detail', slug=slug))
@app.route('/scenes/bulk_create', methods=['POST'])
def bulk_create_scenes_from_loras():
_s = Settings.query.first()
@@ -483,74 +421,9 @@ def register_routes(app):
flash('Scene created successfully!')
return redirect(url_for('scene_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create scene: {e}")
return render_template('scenes/create.html', form_data=form_data)
return render_template('scenes/create.html', form_data=form_data)
@app.route('/scene/<path:slug>/clone', methods=['POST'])
def clone_scene(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
base_id = scene.scene_id
import re
match = re.match(r'^(.+?)_(\d+)$', base_id)
if match:
base_name = match.group(1)
current_num = int(match.group(2))
else:
base_name = base_id
current_num = 1
next_num = current_num + 1
while True:
new_id = f"{base_name}_{next_num:02d}"
new_filename = f"{new_id}.json"
new_path = os.path.join(app.config['SCENES_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = scene.data.copy()
new_data['scene_id'] = new_id
new_data['scene_name'] = f"{scene.name} (Copy)"
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_scene = Scene(
scene_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['scene_name'], data=new_data
)
db.session.add(new_scene)
db.session.commit()
flash(f'Scene cloned as "{new_id}"!')
return redirect(url_for('scene_detail', slug=new_slug))
@app.route('/scene/<path:slug>/save_json', methods=['POST'])
def save_scene_json(slug):
scene = Scene.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
scene.data = new_data
flag_modified(scene, 'data')
db.session.commit()
if scene.filename:
file_path = os.path.join(app.config['SCENES_DIR'], scene.filename)
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))

422
routes/shared.py Normal file
View File

@@ -0,0 +1,422 @@
"""
Shared route factory functions for common patterns across all resource categories.
Each factory registers a route on the Flask app using the closure pattern,
preserving endpoint names for url_for() compatibility.
"""
import json
import logging
import os
import re
from flask import current_app, flash, redirect, request, url_for
from sqlalchemy.orm.attributes import flag_modified
from werkzeug.utils import secure_filename
from models import db
from utils import allowed_file
logger = logging.getLogger('gaze')
# ---------------------------------------------------------------------------
# Category configuration registry
# ---------------------------------------------------------------------------
# Each entry maps a category name to its metadata.
# 'url_prefix': the URL segment (e.g. 'outfit' → /outfit/<slug>/...)
# 'detail_endpoint': the Flask endpoint name for the detail page
# 'config_dir': the app.config key for the JSON data directory
# 'category_folder': subfolder under static/uploads/
# 'id_field': JSON key for entity ID
# 'name_field': JSON key for display name
CATEGORY_CONFIG = {
'characters': {
'model': None, # Set at import time to avoid circular imports
'url_prefix': 'character',
'detail_endpoint': 'detail',
'config_dir': 'CHARACTERS_DIR',
'category_folder': 'characters',
'id_field': 'character_id',
'name_field': 'character_name',
# Characters use unprefixed endpoint names
'endpoint_prefix': '',
},
'outfits': {
'model': None,
'url_prefix': 'outfit',
'detail_endpoint': 'outfit_detail',
'config_dir': 'CLOTHING_DIR',
'category_folder': 'outfits',
'id_field': 'outfit_id',
'name_field': 'outfit_name',
'endpoint_prefix': 'outfit_',
},
'actions': {
'model': None,
'url_prefix': 'action',
'detail_endpoint': 'action_detail',
'config_dir': 'ACTIONS_DIR',
'category_folder': 'actions',
'id_field': 'action_id',
'name_field': 'action_name',
'endpoint_prefix': 'action_',
},
'styles': {
'model': None,
'url_prefix': 'style',
'detail_endpoint': 'style_detail',
'config_dir': 'STYLES_DIR',
'category_folder': 'styles',
'id_field': 'style_id',
'name_field': 'style_name',
'endpoint_prefix': 'style_',
},
'scenes': {
'model': None,
'url_prefix': 'scene',
'detail_endpoint': 'scene_detail',
'config_dir': 'SCENES_DIR',
'category_folder': 'scenes',
'id_field': 'scene_id',
'name_field': 'scene_name',
'endpoint_prefix': 'scene_',
},
'detailers': {
'model': None,
'url_prefix': 'detailer',
'detail_endpoint': 'detailer_detail',
'config_dir': 'DETAILERS_DIR',
'category_folder': 'detailers',
'id_field': 'detailer_id',
'name_field': 'detailer_name',
'endpoint_prefix': 'detailer_',
},
'looks': {
'model': None,
'url_prefix': 'look',
'detail_endpoint': 'look_detail',
'config_dir': 'LOOKS_DIR',
'category_folder': 'looks',
'id_field': 'look_id',
'name_field': 'look_name',
'endpoint_prefix': 'look_',
},
'checkpoints': {
'model': None,
'url_prefix': 'checkpoint',
'detail_endpoint': 'checkpoint_detail',
'config_dir': 'CHECKPOINTS_DIR',
'category_folder': 'checkpoints',
'id_field': 'checkpoint_path',
'name_field': 'checkpoint_name',
'endpoint_prefix': 'checkpoint_',
},
'presets': {
'model': None,
'url_prefix': 'preset',
'detail_endpoint': 'preset_detail',
'config_dir': 'PRESETS_DIR',
'category_folder': 'presets',
'id_field': 'preset_id',
'name_field': 'preset_name',
'endpoint_prefix': 'preset_',
},
}
def _init_models():
"""Lazily populate model references to avoid circular imports."""
from models import (Action, Character, Checkpoint, Detailer, Look,
Outfit, Preset, Scene, Style)
CATEGORY_CONFIG['characters']['model'] = Character
CATEGORY_CONFIG['outfits']['model'] = Outfit
CATEGORY_CONFIG['actions']['model'] = Action
CATEGORY_CONFIG['styles']['model'] = Style
CATEGORY_CONFIG['scenes']['model'] = Scene
CATEGORY_CONFIG['detailers']['model'] = Detailer
CATEGORY_CONFIG['looks']['model'] = Look
CATEGORY_CONFIG['checkpoints']['model'] = Checkpoint
CATEGORY_CONFIG['presets']['model'] = Preset
def _get_config(category):
"""Get config for a category, initializing models if needed."""
cfg = CATEGORY_CONFIG[category]
if cfg['model'] is None:
_init_models()
return cfg
# ---------------------------------------------------------------------------
# Factory functions
# ---------------------------------------------------------------------------
def _register_favourite_route(app, cfg):
"""Register POST /<prefix>/<slug>/favourite toggle route."""
Model = cfg['model']
prefix = cfg['url_prefix']
detail_ep = cfg['detail_endpoint']
ep_prefix = cfg['endpoint_prefix']
# Characters use 'toggle_character_favourite', others use 'toggle_{prefix}_favourite'
if ep_prefix == '':
endpoint_name = 'toggle_character_favourite'
else:
endpoint_name = f'toggle_{prefix}_favourite'
@app.route(f'/{prefix}/<path:slug>/favourite', methods=['POST'],
endpoint=endpoint_name)
def favourite_toggle(slug):
entity = Model.query.filter_by(slug=slug).first_or_404()
entity.is_favourite = not entity.is_favourite
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return {'success': True, 'is_favourite': entity.is_favourite}
return redirect(url_for(detail_ep, slug=slug))
def _register_upload_route(app, cfg):
"""Register POST /<prefix>/<slug>/upload image route."""
Model = cfg['model']
prefix = cfg['url_prefix']
detail_ep = cfg['detail_endpoint']
ep_prefix = cfg['endpoint_prefix']
folder = cfg['category_folder']
if ep_prefix == '':
endpoint_name = 'upload_image'
else:
endpoint_name = f'upload_{prefix}_image'
@app.route(f'/{prefix}/<path:slug>/upload', methods=['POST'],
endpoint=endpoint_name)
def upload_image(slug):
entity = Model.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
entity_folder = os.path.join(
current_app.config['UPLOAD_FOLDER'], f"{folder}/{slug}")
os.makedirs(entity_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(entity_folder, filename)
file.save(file_path)
entity.image_path = f"{folder}/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for(detail_ep, slug=slug))
def _register_replace_cover_route(app, cfg):
"""Register POST /<prefix>/<slug>/replace_cover_from_preview route."""
Model = cfg['model']
prefix = cfg['url_prefix']
detail_ep = cfg['detail_endpoint']
ep_prefix = cfg['endpoint_prefix']
if ep_prefix == '':
endpoint_name = 'replace_cover_from_preview'
else:
endpoint_name = f'replace_{prefix}_cover_from_preview'
@app.route(f'/{prefix}/<path:slug>/replace_cover_from_preview',
methods=['POST'], endpoint=endpoint_name)
def replace_cover(slug):
entity = Model.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(
os.path.join(current_app.config['UPLOAD_FOLDER'], preview_path)):
entity.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for(detail_ep, slug=slug))
def _register_save_defaults_route(app, cfg):
"""Register POST /<prefix>/<slug>/save_defaults route."""
Model = cfg['model']
prefix = cfg['url_prefix']
detail_ep = cfg['detail_endpoint']
ep_prefix = cfg['endpoint_prefix']
category = cfg['category_folder']
if ep_prefix == '':
endpoint_name = 'save_defaults'
else:
endpoint_name = f'save_{prefix}_defaults'
# Display name for the flash message
display = category.rstrip('s')
@app.route(f'/{prefix}/<path:slug>/save_defaults', methods=['POST'],
endpoint=endpoint_name)
def save_defaults(slug):
entity = Model.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
entity.default_fields = selected_fields
db.session.commit()
flash(f'Default prompt selection saved for this {display}!')
return redirect(url_for(detail_ep, slug=slug))
def _register_clone_route(app, cfg):
"""Register POST /<prefix>/<slug>/clone route."""
Model = cfg['model']
prefix = cfg['url_prefix']
detail_ep = cfg['detail_endpoint']
config_dir = cfg['config_dir']
id_field = cfg['id_field']
name_field = cfg['name_field']
endpoint_name = f'clone_{prefix}'
@app.route(f'/{prefix}/<path:slug>/clone', methods=['POST'],
endpoint=endpoint_name)
def clone_entity(slug):
entity = Model.query.filter_by(slug=slug).first_or_404()
base_id = getattr(entity, id_field, None) or entity.data.get(id_field)
match = re.match(r'^(.+?)_(\d+)$', base_id)
if match:
base_name = match.group(1)
current_num = int(match.group(2))
else:
base_name = base_id
current_num = 1
next_num = current_num + 1
while True:
new_id = f"{base_name}_{next_num:02d}"
new_filename = f"{new_id}.json"
new_path = os.path.join(
current_app.config[config_dir], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = dict(entity.data)
new_data[id_field] = new_id
new_data[name_field] = f"{entity.name} (Copy)"
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
kwargs = {
id_field: new_id,
'slug': new_slug,
'filename': new_filename,
'name': new_data[name_field],
'data': new_data,
}
new_entity = Model(**kwargs)
db.session.add(new_entity)
db.session.commit()
flash(f'Cloned as "{new_id}"!')
return redirect(url_for(detail_ep, slug=new_slug))
def _register_save_json_route(app, cfg):
"""Register POST /<prefix>/<slug>/save_json route."""
Model = cfg['model']
prefix = cfg['url_prefix']
config_dir = cfg['config_dir']
ep_prefix = cfg['endpoint_prefix']
if ep_prefix == '':
endpoint_name = 'save_character_json'
else:
endpoint_name = f'save_{prefix}_json'
@app.route(f'/{prefix}/<path:slug>/save_json', methods=['POST'],
endpoint=endpoint_name)
def save_json(slug):
entity = Model.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
entity.data = new_data
flag_modified(entity, 'data')
db.session.commit()
if entity.filename:
file_path = os.path.join(
current_app.config[config_dir], entity.filename)
with open(file_path, 'w') as f:
json.dump(new_data, f, indent=2)
return {'success': True}
def _register_get_missing_route(app, cfg):
"""Register GET /get_missing_<category> route."""
Model = cfg['model']
category = cfg['category_folder']
endpoint_name = f'get_missing_{category}'
@app.route(f'/get_missing_{category}', endpoint=endpoint_name)
def get_missing():
missing = Model.query.filter(
(Model.image_path == None) | (Model.image_path == '')
).order_by(Model.name).all()
return {'missing': [{'slug': e.slug, 'name': e.name} for e in missing]}
def _register_clear_covers_route(app, cfg):
"""Register POST /clear_all_<category>_covers route."""
Model = cfg['model']
category = cfg['category_folder']
ep_prefix = cfg['endpoint_prefix']
# Characters use 'clear_all_covers', others use 'clear_all_{category}_covers'
if ep_prefix == '':
endpoint_name = 'clear_all_covers'
url = '/clear_all_covers'
else:
endpoint_name = f'clear_all_{category.rstrip("s")}_covers'
url = f'/clear_all_{category.rstrip("s")}_covers'
@app.route(url, methods=['POST'], endpoint=endpoint_name)
def clear_covers():
entities = Model.query.all()
for entity in entities:
entity.image_path = None
db.session.commit()
return {'success': True}
# ---------------------------------------------------------------------------
# Main registration function
# ---------------------------------------------------------------------------
def register_common_routes(app, category):
"""Register all common routes for a category.
Call this from each route module's register_routes(app) function.
"""
cfg = _get_config(category)
_register_favourite_route(app, cfg)
_register_upload_route(app, cfg)
_register_replace_cover_route(app, cfg)
_register_save_defaults_route(app, cfg)
_register_clone_route(app, cfg)
_register_save_json_route(app, cfg)
_register_get_missing_route(app, cfg)
_register_clear_covers_route(app, cfg)

View File

@@ -263,7 +263,7 @@ def register_routes(app):
else:
character = None
print(f"[Strengths] char_slug={char_slug!r} character={character.slug if character else 'none'}")
logger.debug("Strengths: char_slug=%r -> character=%s", char_slug, character.slug if character else 'none')
action_obj = None
extra_positive = ''
@@ -274,7 +274,7 @@ def register_routes(app):
action_obj = Action.query.filter_by(slug=action_slug).first()
extra_positive = session.get(f'extra_pos_detailer_{slug}', '')
extra_negative = session.get(f'extra_neg_detailer_{slug}', '')
print(f"[Strengths] detailer session char={char_slug}, action={action_slug}, extra_pos={bool(extra_positive)}, extra_neg={bool(extra_negative)}")
logger.debug("Strengths: detailer session -- char=%s, action=%s, extra_pos=%s, extra_neg=%s", char_slug, action_slug, bool(extra_positive), bool(extra_negative))
prompts = _build_strengths_prompts(category, entity, character,
action=action_obj, extra_positive=extra_positive)
@@ -321,7 +321,7 @@ def register_routes(app):
return {'status': 'queued', 'job_id': job['id']}
except Exception as e:
print(f"[Strengths] generate error: {e}")
logger.exception("Strengths generate error: %s", e)
return {'error': str(e)}, 500
@app.route('/strengths/<category>/<path:slug>/list')

View File

@@ -4,23 +4,24 @@ import re
import random
import logging
from flask import render_template, request, redirect, url_for, flash, session, current_app
from werkzeug.utils import secure_filename
from flask import render_template, request, redirect, url_for, flash, session
from sqlalchemy.orm.attributes import flag_modified
from models import db, Character, Style, Detailer, Settings
from models import db, Character, Style, Settings
from services.workflow import _prepare_workflow, _get_default_checkpoint
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
from services.llm import load_prompt, call_llm
from utils import allowed_file, _WARDROBE_KEYS
from routes.shared import register_common_routes
from utils import _WARDROBE_KEYS
logger = logging.getLogger('gaze')
def register_routes(app):
register_common_routes(app, 'styles')
def _build_style_workflow(style_obj, character=None, selected_fields=None, fixed_seed=None, extra_positive=None, extra_negative=None):
"""Build and return a prepared ComfyUI workflow dict for a style generation."""
@@ -188,40 +189,11 @@ def register_routes(app):
return redirect(url_for('style_detail', slug=slug))
except Exception as e:
print(f"Edit error: {e}")
logger.exception("Edit error: %s", e)
flash(f"Error saving changes: {str(e)}")
return render_template('styles/edit.html', style=style, loras=loras)
@app.route('/style/<path:slug>/upload', methods=['POST'])
def upload_style_image(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
if 'image' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['image']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
# Create style subfolder
style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}")
os.makedirs(style_folder, exist_ok=True)
filename = secure_filename(file.filename)
file_path = os.path.join(style_folder, filename)
file.save(file_path)
# Store relative path in DB
style.image_path = f"styles/{slug}/{filename}"
db.session.commit()
flash('Image uploaded successfully!')
return redirect(url_for('style_detail', slug=slug))
@app.route('/style/<path:slug>/generate', methods=['POST'])
def generate_style_image(slug):
style_obj = Style.query.filter_by(slug=slug).first_or_404()
@@ -269,59 +241,12 @@ def register_routes(app):
return redirect(url_for('style_detail', slug=slug))
except Exception as e:
print(f"Generation error: {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('style_detail', slug=slug))
@app.route('/style/<path:slug>/save_defaults', methods=['POST'])
def save_style_defaults(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
selected_fields = request.form.getlist('include_field')
style.default_fields = selected_fields
db.session.commit()
flash('Default prompt selection saved for this style!')
return redirect(url_for('style_detail', slug=slug))
@app.route('/style/<path:slug>/replace_cover_from_preview', methods=['POST'])
def replace_style_cover_from_preview(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
preview_path = request.form.get('preview_path')
if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)):
style.image_path = preview_path
db.session.commit()
flash('Cover image updated!')
else:
flash('No valid preview image selected.', 'error')
return redirect(url_for('style_detail', slug=slug))
@app.route('/get_missing_styles')
def get_missing_styles():
missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.name).all()
return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]}
@app.route('/get_missing_detailers')
def get_missing_detailers():
missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.name).all()
return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]}
@app.route('/clear_all_detailer_covers', methods=['POST'])
def clear_all_detailer_covers():
detailers = Detailer.query.all()
for detailer in detailers:
detailer.image_path = None
db.session.commit()
return {'success': True}
@app.route('/clear_all_style_covers', methods=['POST'])
def clear_all_style_covers():
styles = Style.query.all()
for style in styles:
style.image_path = None
db.session.commit()
return {'success': True}
@app.route('/styles/generate_missing', methods=['POST'])
def generate_missing_styles():
missing = Style.query.filter(
@@ -347,7 +272,7 @@ def register_routes(app):
_make_finalize('styles', style_obj.slug, Style))
enqueued += 1
except Exception as e:
print(f"Error queuing cover generation for style {style_obj.name}: {e}")
logger.exception("Error queuing cover generation for style %s: %s", style_obj.name, e)
flash(f"Queued {enqueued} style cover image generation job{'s' if enqueued != 1 else ''}.")
return redirect(url_for('styles_index'))
@@ -511,73 +436,9 @@ def register_routes(app):
flash('Style created successfully!')
return redirect(url_for('style_detail', slug=safe_slug))
except Exception as e:
print(f"Save error: {e}")
logger.exception("Save error: %s", e)
flash(f"Failed to create style: {e}")
return render_template('styles/create.html', form_data=form_data)
return render_template('styles/create.html', form_data=form_data)
@app.route('/style/<path:slug>/clone', methods=['POST'])
def clone_style(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
base_id = style.style_id
match = re.match(r'^(.+?)_(\d+)$', base_id)
if match:
base_name = match.group(1)
current_num = int(match.group(2))
else:
base_name = base_id
current_num = 1
next_num = current_num + 1
while True:
new_id = f"{base_name}_{next_num:02d}"
new_filename = f"{new_id}.json"
new_path = os.path.join(app.config['STYLES_DIR'], new_filename)
if not os.path.exists(new_path):
break
next_num += 1
new_data = style.data.copy()
new_data['style_id'] = new_id
new_data['style_name'] = f"{style.name} (Copy)"
with open(new_path, 'w') as f:
json.dump(new_data, f, indent=2)
new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id)
new_style = Style(
style_id=new_id, slug=new_slug, filename=new_filename,
name=new_data['style_name'], data=new_data
)
db.session.add(new_style)
db.session.commit()
flash(f'Style cloned as "{new_id}"!')
return redirect(url_for('style_detail', slug=new_slug))
@app.route('/style/<path:slug>/save_json', methods=['POST'])
def save_style_json(slug):
style = Style.query.filter_by(slug=slug).first_or_404()
try:
new_data = json.loads(request.form.get('json_data', ''))
except (ValueError, TypeError) as e:
return {'success': False, 'error': f'Invalid JSON: {e}'}, 400
style.data = new_data
flag_modified(style, 'data')
db.session.commit()
if style.filename:
file_path = os.path.join(app.config['STYLES_DIR'], style.filename)
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))

View File

@@ -272,8 +272,7 @@ def _make_finalize(category, slug, db_model_class=None, action=None, metadata=No
logger.debug("=" * 80)
return
logger.warning("FINALIZE - No images found in outputs!")
logger.debug("=" * 80)
raise RuntimeError("No images found in ComfyUI outputs")
return _finalize

View File

@@ -1,12 +1,15 @@
import os
import json
import asyncio
import logging
import requests
from flask import has_request_context, request as flask_request
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from models import Settings
logger = logging.getLogger('gaze')
DANBOORU_TOOLS = [
{
"type": "function",
@@ -73,7 +76,7 @@ def call_mcp_tool(name, arguments):
try:
return asyncio.run(_run_mcp_tool(name, arguments))
except Exception as e:
print(f"MCP Tool Error: {e}")
logger.error("MCP Tool Error: %s", e)
return json.dumps({"error": str(e)})
@@ -95,7 +98,7 @@ def call_character_mcp_tool(name, arguments):
try:
return asyncio.run(_run_character_mcp_tool(name, arguments))
except Exception as e:
print(f"Character MCP Tool Error: {e}")
logger.error("Character MCP Tool Error: %s", e)
return None
@@ -164,7 +167,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
# If 400 Bad Request and we were using tools, try once without tools
if response.status_code == 400 and use_tools:
print(f"LLM Provider {settings.llm_provider} rejected tools. Retrying without tool calling...")
logger.warning("LLM Provider %s rejected tools. Retrying without tool calling...", settings.llm_provider)
use_tools = False
max_turns += 1 # Reset turn for the retry
continue
@@ -186,7 +189,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
for tool_call in message['tool_calls']:
name = tool_call['function']['name']
args = json.loads(tool_call['function']['arguments'])
print(f"Executing MCP tool: {name}({args})")
logger.debug("Executing MCP tool: %s(%s)", name, args)
tool_result = call_mcp_tool(name, args)
messages.append({
"role": "tool",
@@ -195,7 +198,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
"content": tool_result
})
if tool_turns_remaining <= 0:
print("Tool turn limit reached — next request will not offer tools")
logger.warning("Tool turn limit reached — next request will not offer tools")
continue
return message['content']
@@ -209,7 +212,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
raw = ""
try: raw = response.text[:500]
except: pass
print(f"Unexpected LLM response format (key={e}). Raw response: {raw}")
logger.warning("Unexpected LLM response format (key=%s). Raw response: %s", e, raw)
if format_retries > 0:
format_retries -= 1
max_turns += 1 # don't burn a turn on a format error
@@ -222,7 +225,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
"Do not include any explanation or markdown — only the raw JSON object."
)
})
print(f"Retrying after format error ({format_retries} retries left)…")
logger.info("Retrying after format error (%d retries left)…", format_retries)
continue
raise RuntimeError(f"Unexpected LLM response format after retries: {str(e)}") from e

View File

@@ -1,6 +1,9 @@
import os
import logging
import subprocess
logger = logging.getLogger('gaze')
# Path to the MCP docker-compose projects, relative to the main app file.
MCP_TOOLS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'tools')
MCP_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'danbooru-mcp')
@@ -19,28 +22,28 @@ def _ensure_mcp_repo():
os.makedirs(MCP_TOOLS_DIR, exist_ok=True)
try:
if not os.path.isdir(MCP_COMPOSE_DIR):
print(f'Cloning danbooru-mcp from {MCP_REPO_URL}')
logger.info('Cloning danbooru-mcp from %s', MCP_REPO_URL)
subprocess.run(
['git', 'clone', MCP_REPO_URL, MCP_COMPOSE_DIR],
timeout=120, check=True,
)
print('danbooru-mcp cloned successfully.')
logger.info('danbooru-mcp cloned successfully.')
else:
print('Updating danbooru-mcp via git pull …')
logger.info('Updating danbooru-mcp via git pull …')
subprocess.run(
['git', 'pull'],
cwd=MCP_COMPOSE_DIR,
timeout=60, check=True,
)
print('danbooru-mcp updated.')
logger.info('danbooru-mcp updated.')
except FileNotFoundError:
print('WARNING: git not found on PATH — danbooru-mcp repo will not be cloned/updated.')
logger.warning('git not found on PATH — danbooru-mcp repo will not be cloned/updated.')
except subprocess.CalledProcessError as e:
print(f'WARNING: git operation failed for danbooru-mcp: {e}')
logger.warning('git operation failed for danbooru-mcp: %s', e)
except subprocess.TimeoutExpired:
print('WARNING: git timed out while cloning/updating danbooru-mcp.')
logger.warning('git timed out while cloning/updating danbooru-mcp.')
except Exception as e:
print(f'WARNING: Could not clone/update danbooru-mcp repo: {e}')
logger.warning('Could not clone/update danbooru-mcp repo: %s', e)
def ensure_mcp_server_running():
@@ -55,7 +58,7 @@ def ensure_mcp_server_running():
danbooru-mcp service is managed by compose instead).
"""
if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true':
print('SKIP_MCP_AUTOSTART set — skipping danbooru-mcp auto-start.')
logger.info('SKIP_MCP_AUTOSTART set — skipping danbooru-mcp auto-start.')
return
_ensure_mcp_repo()
try:
@@ -64,22 +67,22 @@ def ensure_mcp_server_running():
capture_output=True, text=True, timeout=10,
)
if 'danbooru-mcp' in result.stdout:
print('danbooru-mcp container already running.')
logger.info('danbooru-mcp container already running.')
return
# Container not running — start it via docker compose
print('Starting danbooru-mcp container via docker compose …')
logger.info('Starting danbooru-mcp container via docker compose …')
subprocess.run(
['docker', 'compose', 'up', '-d'],
cwd=MCP_COMPOSE_DIR,
timeout=120,
)
print('danbooru-mcp container started.')
logger.info('danbooru-mcp container started.')
except FileNotFoundError:
print('WARNING: docker not found on PATH — danbooru-mcp will not be started automatically.')
logger.warning('docker not found on PATH — danbooru-mcp will not be started automatically.')
except subprocess.TimeoutExpired:
print('WARNING: docker timed out while starting danbooru-mcp.')
logger.warning('docker timed out while starting danbooru-mcp.')
except Exception as e:
print(f'WARNING: Could not ensure danbooru-mcp is running: {e}')
logger.warning('Could not ensure danbooru-mcp is running: %s', e)
def _ensure_character_mcp_repo():
@@ -92,28 +95,28 @@ def _ensure_character_mcp_repo():
os.makedirs(MCP_TOOLS_DIR, exist_ok=True)
try:
if not os.path.isdir(CHAR_MCP_COMPOSE_DIR):
print(f'Cloning character-mcp from {CHAR_MCP_REPO_URL}')
logger.info('Cloning character-mcp from %s', CHAR_MCP_REPO_URL)
subprocess.run(
['git', 'clone', CHAR_MCP_REPO_URL, CHAR_MCP_COMPOSE_DIR],
timeout=120, check=True,
)
print('character-mcp cloned successfully.')
logger.info('character-mcp cloned successfully.')
else:
print('Updating character-mcp via git pull …')
logger.info('Updating character-mcp via git pull …')
subprocess.run(
['git', 'pull'],
cwd=CHAR_MCP_COMPOSE_DIR,
timeout=60, check=True,
)
print('character-mcp updated.')
logger.info('character-mcp updated.')
except FileNotFoundError:
print('WARNING: git not found on PATH — character-mcp repo will not be cloned/updated.')
logger.warning('git not found on PATH — character-mcp repo will not be cloned/updated.')
except subprocess.CalledProcessError as e:
print(f'WARNING: git operation failed for character-mcp: {e}')
logger.warning('git operation failed for character-mcp: %s', e)
except subprocess.TimeoutExpired:
print('WARNING: git timed out while cloning/updating character-mcp.')
logger.warning('git timed out while cloning/updating character-mcp.')
except Exception as e:
print(f'WARNING: Could not clone/update character-mcp repo: {e}')
logger.warning('Could not clone/update character-mcp repo: %s', e)
def ensure_character_mcp_server_running():
@@ -128,7 +131,7 @@ def ensure_character_mcp_server_running():
character-mcp service is managed by compose instead).
"""
if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true':
print('SKIP_MCP_AUTOSTART set — skipping character-mcp auto-start.')
logger.info('SKIP_MCP_AUTOSTART set — skipping character-mcp auto-start.')
return
_ensure_character_mcp_repo()
try:
@@ -137,19 +140,19 @@ def ensure_character_mcp_server_running():
capture_output=True, text=True, timeout=10,
)
if 'character-mcp' in result.stdout:
print('character-mcp container already running.')
logger.info('character-mcp container already running.')
return
# Container not running — start it via docker compose
print('Starting character-mcp container via docker compose …')
logger.info('Starting character-mcp container via docker compose …')
subprocess.run(
['docker', 'compose', 'up', '-d'],
cwd=CHAR_MCP_COMPOSE_DIR,
timeout=120,
)
print('character-mcp container started.')
logger.info('character-mcp container started.')
except FileNotFoundError:
print('WARNING: docker not found on PATH — character-mcp will not be started automatically.')
logger.warning('docker not found on PATH — character-mcp will not be started automatically.')
except subprocess.TimeoutExpired:
print('WARNING: docker timed out while starting character-mcp.')
logger.warning('docker timed out while starting character-mcp.')
except Exception as e:
print(f'WARNING: Could not ensure character-mcp is running: {e}')
logger.warning('Could not ensure character-mcp is running: %s', e)

View File

@@ -57,7 +57,7 @@ def sync_characters():
if character.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], character.image_path)
if not os.path.exists(full_img_path):
print(f"Image missing for {character.name}, clearing path.")
logger.warning("Image missing for %s, clearing path.", character.name)
character.image_path = None
# Explicitly tell SQLAlchemy the JSON field was modified
@@ -73,7 +73,7 @@ def sync_characters():
_sync_nsfw_from_tags(new_char, data)
db.session.add(new_char)
except Exception as e:
print(f"Error importing {filename}: {e}")
logger.error("Error importing %s: %s", filename, e)
# Remove characters that are no longer in the folder
all_characters = Character.query.all()
@@ -83,66 +83,82 @@ def sync_characters():
db.session.commit()
def sync_outfits():
if not os.path.exists(current_app.config['CLOTHING_DIR']):
def _sync_category(config_key, model_class, id_field, name_field,
extra_fn=None, sync_nsfw=True):
"""Generic sync: load JSON files from a data directory into the database.
Args:
config_key: app.config key for the data directory (e.g. 'CLOTHING_DIR')
model_class: SQLAlchemy model class (e.g. Outfit)
id_field: JSON key for the entity ID (e.g. 'outfit_id')
name_field: JSON key for the display name (e.g. 'outfit_name')
extra_fn: optional callable(entity, data) for category-specific field updates
sync_nsfw: if True, call _sync_nsfw_from_tags on create/update
"""
data_dir = current_app.config.get(config_key)
if not data_dir or not os.path.exists(data_dir):
return
current_ids = []
for filename in os.listdir(current_app.config['CLOTHING_DIR']):
for filename in os.listdir(data_dir):
if filename.endswith('.json'):
file_path = os.path.join(current_app.config['CLOTHING_DIR'], filename)
file_path = os.path.join(data_dir, filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
outfit_id = data.get('outfit_id') or filename.replace('.json', '')
entity_id = data.get(id_field) or filename.replace('.json', '')
current_ids.append(entity_id)
current_ids.append(outfit_id)
slug = re.sub(r'[^a-zA-Z0-9_]', '', entity_id)
entity = model_class.query.filter_by(**{id_field: entity_id}).first()
name = data.get(name_field, entity_id.replace('_', ' ').title())
# Generate URL-safe slug: remove special characters from outfit_id
slug = re.sub(r'[^a-zA-Z0-9_]', '', outfit_id)
if entity:
entity.data = data
entity.name = name
entity.slug = slug
entity.filename = filename
if sync_nsfw:
_sync_nsfw_from_tags(entity, data)
if extra_fn:
extra_fn(entity, data)
# Check if outfit already exists
outfit = Outfit.query.filter_by(outfit_id=outfit_id).first()
name = data.get('outfit_name', outfit_id.replace('_', ' ').title())
if outfit:
outfit.data = data
outfit.name = name
outfit.slug = slug
outfit.filename = filename
_sync_nsfw_from_tags(outfit, data)
# Check if cover image still exists
if outfit.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], outfit.image_path)
if entity.image_path:
full_img_path = os.path.join(
current_app.config['UPLOAD_FOLDER'], entity.image_path)
if not os.path.exists(full_img_path):
print(f"Image missing for {outfit.name}, clearing path.")
outfit.image_path = None
logger.warning("Image missing for %s, clearing path.", entity.name)
entity.image_path = None
# Explicitly tell SQLAlchemy the JSON field was modified
flag_modified(outfit, "data")
flag_modified(entity, "data")
else:
new_outfit = Outfit(
outfit_id=outfit_id,
slug=slug,
filename=filename,
name=name,
data=data
)
_sync_nsfw_from_tags(new_outfit, data)
db.session.add(new_outfit)
kwargs = {
id_field: entity_id,
'slug': slug,
'filename': filename,
'name': name,
'data': data,
}
new_entity = model_class(**kwargs)
if sync_nsfw:
_sync_nsfw_from_tags(new_entity, data)
if extra_fn:
extra_fn(new_entity, data)
db.session.add(new_entity)
except Exception as e:
print(f"Error importing outfit {filename}: {e}")
logger.error("Error importing %s: %s", filename, e)
# Remove outfits that are no longer in the folder
all_outfits = Outfit.query.all()
for outfit in all_outfits:
if outfit.outfit_id not in current_ids:
db.session.delete(outfit)
for entity in model_class.query.all():
if getattr(entity, id_field) not in current_ids:
db.session.delete(entity)
db.session.commit()
def sync_outfits():
_sync_category('CLOTHING_DIR', Outfit, 'outfit_id', 'outfit_name')
def ensure_default_outfit():
"""Ensure a default outfit file exists and is registered in the database.
@@ -226,114 +242,16 @@ def ensure_default_outfit():
def _sync_look_extra(entity, data):
entity.character_id = data.get('character_id', None)
def sync_looks():
if not os.path.exists(current_app.config['LOOKS_DIR']):
return
current_ids = []
for filename in os.listdir(current_app.config['LOOKS_DIR']):
if filename.endswith('.json'):
file_path = os.path.join(current_app.config['LOOKS_DIR'], filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
look_id = data.get('look_id') or filename.replace('.json', '')
current_ids.append(look_id)
slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id)
look = Look.query.filter_by(look_id=look_id).first()
name = data.get('look_name', look_id.replace('_', ' ').title())
character_id = data.get('character_id', None)
if look:
look.data = data
look.name = name
look.slug = slug
look.filename = filename
look.character_id = character_id
_sync_nsfw_from_tags(look, data)
if look.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], look.image_path)
if not os.path.exists(full_img_path):
look.image_path = None
flag_modified(look, "data")
else:
new_look = Look(
look_id=look_id,
slug=slug,
filename=filename,
name=name,
character_id=character_id,
data=data
)
_sync_nsfw_from_tags(new_look, data)
db.session.add(new_look)
except Exception as e:
print(f"Error importing look {filename}: {e}")
all_looks = Look.query.all()
for look in all_looks:
if look.look_id not in current_ids:
db.session.delete(look)
db.session.commit()
_sync_category('LOOKS_DIR', Look, 'look_id', 'look_name',
extra_fn=_sync_look_extra)
def sync_presets():
if not os.path.exists(current_app.config['PRESETS_DIR']):
return
current_ids = []
for filename in os.listdir(current_app.config['PRESETS_DIR']):
if filename.endswith('.json'):
file_path = os.path.join(current_app.config['PRESETS_DIR'], filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
preset_id = data.get('preset_id') or filename.replace('.json', '')
current_ids.append(preset_id)
slug = re.sub(r'[^a-zA-Z0-9_]', '', preset_id)
preset = Preset.query.filter_by(preset_id=preset_id).first()
name = data.get('preset_name', preset_id.replace('_', ' ').title())
if preset:
preset.data = data
preset.name = name
preset.slug = slug
preset.filename = filename
if preset.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], preset.image_path)
if not os.path.exists(full_img_path):
preset.image_path = None
flag_modified(preset, "data")
else:
new_preset = Preset(
preset_id=preset_id,
slug=slug,
filename=filename,
name=name,
data=data
)
db.session.add(new_preset)
except Exception as e:
print(f"Error importing preset {filename}: {e}")
all_presets = Preset.query.all()
for preset in all_presets:
if preset.preset_id not in current_ids:
db.session.delete(preset)
db.session.commit()
_sync_category('PRESETS_DIR', Preset, 'preset_id', 'preset_name',
sync_nsfw=False)
# ---------------------------------------------------------------------------
@@ -404,240 +322,16 @@ def _resolve_preset_fields(preset_data):
def sync_actions():
if not os.path.exists(current_app.config['ACTIONS_DIR']):
return
current_ids = []
for filename in os.listdir(current_app.config['ACTIONS_DIR']):
if filename.endswith('.json'):
file_path = os.path.join(current_app.config['ACTIONS_DIR'], filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
action_id = data.get('action_id') or filename.replace('.json', '')
current_ids.append(action_id)
# Generate URL-safe slug
slug = re.sub(r'[^a-zA-Z0-9_]', '', action_id)
# Check if action already exists
action = Action.query.filter_by(action_id=action_id).first()
name = data.get('action_name', action_id.replace('_', ' ').title())
if action:
action.data = data
action.name = name
action.slug = slug
action.filename = filename
_sync_nsfw_from_tags(action, data)
# Check if cover image still exists
if action.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], action.image_path)
if not os.path.exists(full_img_path):
print(f"Image missing for {action.name}, clearing path.")
action.image_path = None
flag_modified(action, "data")
else:
new_action = Action(
action_id=action_id,
slug=slug,
filename=filename,
name=name,
data=data
)
_sync_nsfw_from_tags(new_action, data)
db.session.add(new_action)
except Exception as e:
print(f"Error importing action {filename}: {e}")
# Remove actions that are no longer in the folder
all_actions = Action.query.all()
for action in all_actions:
if action.action_id not in current_ids:
db.session.delete(action)
db.session.commit()
_sync_category('ACTIONS_DIR', Action, 'action_id', 'action_name')
def sync_styles():
if not os.path.exists(current_app.config['STYLES_DIR']):
return
current_ids = []
for filename in os.listdir(current_app.config['STYLES_DIR']):
if filename.endswith('.json'):
file_path = os.path.join(current_app.config['STYLES_DIR'], filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
style_id = data.get('style_id') or filename.replace('.json', '')
current_ids.append(style_id)
# Generate URL-safe slug
slug = re.sub(r'[^a-zA-Z0-9_]', '', style_id)
# Check if style already exists
style = Style.query.filter_by(style_id=style_id).first()
name = data.get('style_name', style_id.replace('_', ' ').title())
if style:
style.data = data
style.name = name
style.slug = slug
style.filename = filename
_sync_nsfw_from_tags(style, data)
# Check if cover image still exists
if style.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], style.image_path)
if not os.path.exists(full_img_path):
print(f"Image missing for {style.name}, clearing path.")
style.image_path = None
flag_modified(style, "data")
else:
new_style = Style(
style_id=style_id,
slug=slug,
filename=filename,
name=name,
data=data
)
_sync_nsfw_from_tags(new_style, data)
db.session.add(new_style)
except Exception as e:
print(f"Error importing style {filename}: {e}")
# Remove styles that are no longer in the folder
all_styles = Style.query.all()
for style in all_styles:
if style.style_id not in current_ids:
db.session.delete(style)
db.session.commit()
_sync_category('STYLES_DIR', Style, 'style_id', 'style_name')
def sync_detailers():
if not os.path.exists(current_app.config['DETAILERS_DIR']):
return
current_ids = []
for filename in os.listdir(current_app.config['DETAILERS_DIR']):
if filename.endswith('.json'):
file_path = os.path.join(current_app.config['DETAILERS_DIR'], filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
detailer_id = data.get('detailer_id') or filename.replace('.json', '')
current_ids.append(detailer_id)
# Generate URL-safe slug
slug = re.sub(r'[^a-zA-Z0-9_]', '', detailer_id)
# Check if detailer already exists
detailer = Detailer.query.filter_by(detailer_id=detailer_id).first()
name = data.get('detailer_name', detailer_id.replace('_', ' ').title())
if detailer:
detailer.data = data
detailer.name = name
detailer.slug = slug
detailer.filename = filename
_sync_nsfw_from_tags(detailer, data)
# Check if cover image still exists
if detailer.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], detailer.image_path)
if not os.path.exists(full_img_path):
print(f"Image missing for {detailer.name}, clearing path.")
detailer.image_path = None
flag_modified(detailer, "data")
else:
new_detailer = Detailer(
detailer_id=detailer_id,
slug=slug,
filename=filename,
name=name,
data=data
)
_sync_nsfw_from_tags(new_detailer, data)
db.session.add(new_detailer)
except Exception as e:
print(f"Error importing detailer {filename}: {e}")
# Remove detailers that are no longer in the folder
all_detailers = Detailer.query.all()
for detailer in all_detailers:
if detailer.detailer_id not in current_ids:
db.session.delete(detailer)
db.session.commit()
_sync_category('DETAILERS_DIR', Detailer, 'detailer_id', 'detailer_name')
def sync_scenes():
if not os.path.exists(current_app.config['SCENES_DIR']):
return
current_ids = []
for filename in os.listdir(current_app.config['SCENES_DIR']):
if filename.endswith('.json'):
file_path = os.path.join(current_app.config['SCENES_DIR'], filename)
try:
with open(file_path, 'r') as f:
data = json.load(f)
scene_id = data.get('scene_id') or filename.replace('.json', '')
current_ids.append(scene_id)
# Generate URL-safe slug
slug = re.sub(r'[^a-zA-Z0-9_]', '', scene_id)
# Check if scene already exists
scene = Scene.query.filter_by(scene_id=scene_id).first()
name = data.get('scene_name', scene_id.replace('_', ' ').title())
if scene:
scene.data = data
scene.name = name
scene.slug = slug
scene.filename = filename
_sync_nsfw_from_tags(scene, data)
# Check if cover image still exists
if scene.image_path:
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], scene.image_path)
if not os.path.exists(full_img_path):
print(f"Image missing for {scene.name}, clearing path.")
scene.image_path = None
flag_modified(scene, "data")
else:
new_scene = Scene(
scene_id=scene_id,
slug=slug,
filename=filename,
name=name,
data=data
)
_sync_nsfw_from_tags(new_scene, data)
db.session.add(new_scene)
except Exception as e:
print(f"Error importing scene {filename}: {e}")
# Remove scenes that are no longer in the folder
all_scenes = Scene.query.all()
for scene in all_scenes:
if scene.scene_id not in current_ids:
db.session.delete(scene)
db.session.commit()
_sync_category('SCENES_DIR', Scene, 'scene_id', 'scene_name')
def _default_checkpoint_data(checkpoint_path, filename):
"""Return template-default data for a checkpoint with no JSON file."""
@@ -650,6 +344,7 @@ def _default_checkpoint_data(checkpoint_path, filename):
"steps": 25,
"cfg": 5,
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"vae": "integrated"
}
@@ -669,7 +364,7 @@ def sync_checkpoints():
if ckpt_path:
json_data_by_path[ckpt_path] = data
except Exception as e:
print(f"Error reading checkpoint JSON {filename}: {e}")
logger.error("Error reading checkpoint JSON %s: %s", filename, e)
current_ids = []
dirs = [

267
static/js/detail-common.js Normal file
View File

@@ -0,0 +1,267 @@
/**
* Shared JS for all resource detail pages.
*
* Usage: call initDetailPage(options) from the template's <script> block.
*
* Options:
* batchItems — Array of {slug, name} for batch generation (optional)
* jsonEditorUrl — URL for save_json route (optional, enables JSON editor)
* hasPreviewGallery — If true, enables addToPreviewGallery helper (default: !!batchItems)
*/
function initDetailPage(options = {}) {
const {
batchItems = [],
jsonEditorUrl = null,
hasPreviewGallery = batchItems.length > 0,
} = options;
// -----------------------------------------------------------------------
// DOM references
// -----------------------------------------------------------------------
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
// -----------------------------------------------------------------------
// Favourite toggle (delegated)
// -----------------------------------------------------------------------
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.success) {
btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
}
});
});
// -----------------------------------------------------------------------
// Preview selection
// -----------------------------------------------------------------------
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
// Click any image with data-preview-path to select it
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
// -----------------------------------------------------------------------
// Job polling
// -----------------------------------------------------------------------
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') {
clearInterval(poll);
resolve(data);
} else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll);
reject(new Error(data.error || 'Job failed'));
} else if (data.status === 'processing') {
progressLabel.textContent = 'Generating\u2026';
} else {
progressLabel.textContent = 'Queued\u2026';
}
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
// -----------------------------------------------------------------------
// Preview gallery helper
// -----------------------------------------------------------------------
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
if (!gallery) return;
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else {
const tab = document.getElementById('previews-tab');
if (tab) tab.insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
}
}
// -----------------------------------------------------------------------
// Form submit handler (single generation)
// -----------------------------------------------------------------------
if (form) {
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing\u2026';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued\u2026';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
if (hasPreviewGallery) {
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
}
if (typeof updateSeedFromResult === 'function') {
updateSeedFromResult(jobResult.result);
}
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
});
}
// -----------------------------------------------------------------------
// Endless mode callback
// -----------------------------------------------------------------------
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
if (hasPreviewGallery) {
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
}
};
// -----------------------------------------------------------------------
// Batch generation (secondary categories only)
// -----------------------------------------------------------------------
if (batchItems.length > 0) {
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
if (generateAllBtn) {
generateAllBtn.addEventListener('click', async () => {
if (batchItems.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
// Phase 1: submit all jobs
batchLabel.textContent = 'Queuing all characters\u2026';
const pending = [];
for (const char of batchItems) {
if (stopBatch) break;
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(
cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
const resp = await fetch(formAction, {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
// Phase 2: poll all in parallel
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) {
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}
}).catch(err => {
done++;
console.error(`Failed for ${char.name}:`, err);
})
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => {
batchProgress.classList.add('d-none');
batchBar.style.width = '0%';
}, 3000);
});
}
if (stopAllBtn) {
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping\u2026';
});
}
}
// -----------------------------------------------------------------------
// JSON editor
// -----------------------------------------------------------------------
if (jsonEditorUrl && typeof initJsonEditor === 'function') {
initJsonEditor(jsonEditorUrl);
}
}

View File

@@ -307,11 +307,24 @@
applyGrid() {
const container = state.dom.container;
if (!container) return;
// Reset any custom positioning
// Reset container styles set by other layouts
container.style.height = '';
container.style.position = '';
container.style.gap = '';
// Reset any custom positioning on cards and their children
state.images.forEach(img => {
if (img.element) {
img.element.style.cssText = '';
// Reset child element inline styles set by mosaic/other layouts
const imgEl = img.element.querySelector('img');
if (imgEl) imgEl.style.cssText = '';
const badge = img.element.querySelector('.cat-badge');
if (badge) badge.style.cssText = '';
const overlay = img.element.querySelector('.overlay');
if (overlay) overlay.style.cssText = '';
}
});
},

204
static/js/layout-utils.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Shared utility functions used across the app layout.
* Previously defined inline in layout.html.
*/
/**
* Confirm and execute a resource delete (soft or hard).
* Called from the delete confirmation modal.
*/
async function confirmResourceDelete(mode) {
const resourceDeleteModal = bootstrap.Modal.getInstance(
document.getElementById('resourceDeleteModal'));
resourceDeleteModal.hide();
try {
const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mode}),
});
const data = await res.json();
if (data.status === 'ok') {
const card = document.getElementById(`card-${_rdmSlug}`);
if (card) card.remove();
} else {
alert('Delete failed: ' + (data.error || 'unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e);
}
}
/**
* Regenerate tags for a single resource via the LLM queue.
* Called from detail page tag regeneration buttons.
*/
function regenerateTags(category, slug) {
const btn = document.getElementById('regenerate-tags-btn');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating\u2026';
fetch(`/api/${category}/${slug}/regenerate_tags`, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
.then(({ok, data}) => {
if (ok && data.success) {
location.reload();
} else {
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = origText;
}
})
.catch(err => {
alert('Regeneration failed: ' + err);
btn.disabled = false;
btn.innerHTML = origText;
});
}
/**
* Initialize the JSON editor modal for a resource detail page.
* Provides simple form view and raw JSON (advanced) editing.
*/
function initJsonEditor(saveUrl) {
const jsonModal = document.getElementById('jsonEditorModal');
if (!jsonModal) return;
const textarea = document.getElementById('json-editor-textarea');
const errBox = document.getElementById('json-editor-error');
const simplePanel = document.getElementById('json-simple-panel');
const advancedPanel = document.getElementById('json-advanced-panel');
const simpleTab = document.getElementById('json-simple-tab');
const advancedTab = document.getElementById('json-advanced-tab');
let activeTab = 'simple';
function buildSimpleForm(data) {
simplePanel.innerHTML = '';
for (const [key, value] of Object.entries(data)) {
const row = document.createElement('div');
row.className = 'row mb-2 align-items-start';
const labelCol = document.createElement('div');
labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1';
labelCol.textContent = key.replace(/_/g, ' ');
const inputCol = document.createElement('div');
inputCol.className = 'col-sm-9';
let el;
if (typeof value === 'boolean') {
const wrap = document.createElement('div');
wrap.className = 'form-check mt-2';
el = document.createElement('input');
el.type = 'checkbox';
el.className = 'form-check-input';
el.checked = value;
el.dataset.dtype = 'boolean';
wrap.appendChild(el);
inputCol.appendChild(wrap);
} else if (typeof value === 'number') {
el = document.createElement('input');
el.type = 'number';
el.step = 'any';
el.className = 'form-control form-control-sm';
el.value = value;
el.dataset.dtype = 'number';
inputCol.appendChild(el);
} else if (typeof value === 'string') {
if (value.length > 80) {
el = document.createElement('textarea');
el.className = 'form-control form-control-sm';
el.rows = 3;
} else {
el = document.createElement('input');
el.type = 'text';
el.className = 'form-control form-control-sm';
}
el.value = value;
el.dataset.dtype = 'string';
inputCol.appendChild(el);
} else {
el = document.createElement('textarea');
el.className = 'form-control form-control-sm font-monospace';
const lines = JSON.stringify(value, null, 2).split('\n');
el.rows = Math.min(10, lines.length + 1);
el.value = JSON.stringify(value, null, 2);
el.dataset.dtype = 'json';
inputCol.appendChild(el);
}
el.dataset.key = key;
row.appendChild(labelCol);
row.appendChild(inputCol);
simplePanel.appendChild(row);
}
}
function readSimpleForm() {
const result = {};
simplePanel.querySelectorAll('[data-key]').forEach(el => {
const key = el.dataset.key;
const dtype = el.dataset.dtype;
if (dtype === 'boolean') result[key] = el.checked;
else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; }
else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } }
else result[key] = el.value;
});
return result;
}
simpleTab.addEventListener('click', () => {
errBox.classList.add('d-none');
let data;
try { data = JSON.parse(textarea.value); }
catch (e) { errBox.textContent = 'Cannot switch: invalid JSON \u2014 ' + e.message; errBox.classList.remove('d-none'); return; }
buildSimpleForm(data);
simplePanel.classList.remove('d-none');
advancedPanel.classList.add('d-none');
simpleTab.classList.add('active');
advancedTab.classList.remove('active');
activeTab = 'simple';
});
advancedTab.addEventListener('click', () => {
textarea.value = JSON.stringify(readSimpleForm(), null, 2);
advancedPanel.classList.remove('d-none');
simplePanel.classList.add('d-none');
advancedTab.classList.add('active');
simpleTab.classList.remove('active');
activeTab = 'advanced';
});
jsonModal.addEventListener('show.bs.modal', () => {
const raw = document.getElementById('json-raw-data').textContent;
let data;
try { data = JSON.parse(raw); } catch { data = {}; }
buildSimpleForm(data);
textarea.value = JSON.stringify(data, null, 2);
simplePanel.classList.remove('d-none');
advancedPanel.classList.add('d-none');
simpleTab.classList.add('active');
advancedTab.classList.remove('active');
activeTab = 'simple';
errBox.classList.add('d-none');
});
document.getElementById('json-save-btn').addEventListener('click', async () => {
errBox.classList.add('d-none');
let parsed;
if (activeTab === 'simple') {
parsed = readSimpleForm();
} else {
try { parsed = JSON.parse(textarea.value); }
catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; }
}
const fd = new FormData();
fd.append('json_data', JSON.stringify(parsed));
const resp = await fetch(saveUrl, { method: 'POST', body: fd });
const result = await resp.json();
if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); }
else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); }
});
}

View File

@@ -0,0 +1,193 @@
/**
* Shared library toolbar JS — handles batch generation, tag regeneration,
* and bulk create operations for all category index pages.
*
* Reads configuration from data attributes on the toolbar wrapper element:
* data-toolbar-category — e.g. "outfits"
* data-get-missing-url — e.g. "/get_missing_outfits"
* data-clear-covers-url — e.g. "/clear_all_outfit_covers"
* data-generate-url — e.g. "/outfit/{slug}/generate" (with {slug} placeholder)
* data-regen-tags-category — e.g. "outfits" (for /admin/bulk_regenerate_tags/<cat>)
* data-bulk-create-url — e.g. "/outfits/bulk_create"
*/
document.addEventListener('DOMContentLoaded', () => {
const toolbar = document.querySelector('[data-toolbar-category]');
if (!toolbar) return;
const category = toolbar.dataset.toolbarCategory;
const getMissingUrl = toolbar.dataset.getMissingUrl;
const clearCoversUrl = toolbar.dataset.clearCoversUrl;
const generateUrlPattern = toolbar.dataset.generateUrl;
const regenTagsCat = toolbar.dataset.regenTagsCategory;
const bulkCreateUrl = toolbar.dataset.bulkCreateUrl;
const batchBtn = document.getElementById('batch-generate-btn');
const regenAllBtn = document.getElementById('regenerate-all-btn');
const regenTagsBtn = document.getElementById('regen-tags-all-btn');
const bulkCreateBtn = document.getElementById('bulk-create-btn');
const bulkOverwriteBtn = document.getElementById('bulk-overwrite-btn');
// --- Utility: poll a job until done ---
function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll); reject(new Error(data.error || 'Job failed'));
}
} catch (err) { /* ignore transient errors */ }
}, 1500);
});
}
// --- Batch Generate Missing Covers ---
async function runBatch() {
if (!getMissingUrl || !generateUrlPattern) return;
const response = await fetch(getMissingUrl);
const data = await response.json();
const missing = data.missing;
if (missing.length === 0) {
alert('No items missing cover images.');
return;
}
if (batchBtn) batchBtn.disabled = true;
if (regenAllBtn) regenAllBtn.disabled = true;
// Phase 1: Queue all jobs
const jobs = [];
for (const item of missing) {
try {
const url = generateUrlPattern.replace('{slug}', item.slug);
const body = new URLSearchParams({ action: 'replace' });
// Secondary categories need a random character for generation
if (category !== 'characters') {
body.set('character_slug', '__random__');
}
const genResp = await fetch(url, {
method: 'POST',
body: body,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const genData = await genResp.json();
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
} catch (err) {
console.error(`Failed to queue ${item.name}:`, err);
}
}
// Phase 2: Poll all concurrently, update card images as they finish
await Promise.all(jobs.map(async ({ item, jobId }) => {
try {
const jobResult = await waitForJob(jobId);
if (jobResult.result && jobResult.result.image_url) {
const img = document.getElementById(`img-${item.slug}`);
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
if (noImgSpan) noImgSpan.classList.add('d-none');
}
} catch (err) {
console.error(`Failed for ${item.name}:`, err);
}
}));
if (batchBtn) batchBtn.disabled = false;
if (regenAllBtn) regenAllBtn.disabled = false;
alert(`Batch generation complete! ${jobs.length} images queued.`);
}
if (batchBtn) {
batchBtn.addEventListener('click', async () => {
if (!getMissingUrl) return;
const response = await fetch(getMissingUrl);
const data = await response.json();
if (data.missing.length === 0) {
alert('No items missing cover images.');
return;
}
if (!confirm(`Generate cover images for ${data.missing.length} items?`)) return;
runBatch();
});
}
// --- Regenerate All Covers ---
if (regenAllBtn) {
regenAllBtn.addEventListener('click', async () => {
if (!clearCoversUrl) return;
if (!confirm('This will unassign ALL current cover images and generate new ones. Proceed?')) return;
const clearResp = await fetch(clearCoversUrl, { method: 'POST' });
if (clearResp.ok) {
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
runBatch();
}
});
}
// --- Regenerate Tags (LLM) ---
if (regenTagsBtn && regenTagsCat) {
regenTagsBtn.addEventListener('click', async () => {
if (!confirm('Regenerate tags for ALL items using the LLM? This will consume API credits.')) return;
regenTagsBtn.disabled = true;
const origText = regenTagsBtn.textContent;
regenTagsBtn.textContent = 'Queuing...';
try {
const resp = await fetch(`/admin/bulk_regenerate_tags/${regenTagsCat}`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (data.success) {
alert(`Queued ${data.queued} tag regeneration tasks. Watch progress in the queue.`);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Request failed: ' + err.message);
}
regenTagsBtn.disabled = false;
regenTagsBtn.textContent = origText;
});
}
// --- Bulk Create from LoRAs (LLM) ---
async function doBulkCreate(overwrite) {
if (!bulkCreateUrl) return;
const body = overwrite ? new URLSearchParams({ overwrite: 'true' }) : undefined;
try {
const resp = await fetch(bulkCreateUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: body,
});
const data = await resp.json();
if (data.success) {
alert(`Queued ${data.queued} LLM tasks (${data.skipped} skipped). Watch progress in the queue.`);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Request failed: ' + err.message);
}
}
if (bulkCreateBtn) {
bulkCreateBtn.addEventListener('click', () => {
if (!confirm('Create entries from LoRA files using the LLM? This will consume API credits.')) return;
doBulkCreate(false);
});
}
if (bulkOverwriteBtn) {
bulkOverwriteBtn.addEventListener('click', () => {
if (!confirm('WARNING: This will overwrite ALL existing metadata using the LLM. This consumes API credits. Proceed?')) return;
doBulkCreate(true);
});
}
});

View File

@@ -294,192 +294,20 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping…';
});
initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_action_json", slug=action.slug) }}'
});
// Character-context toggle (action-specific)
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
});
</script>
{% endblock %}

View File

@@ -71,8 +71,8 @@
{% set lora_name = action.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ action.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="actions" data-slug="{{ action.slug }}" data-name="{{ action.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="actions" data-slug="{{ action.slug }}" data-name="{{ action.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -101,6 +101,19 @@
</div>
</div>
</div>
{% set tags = ckpt.data.tags if ckpt.data is mapping and ckpt.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4">
<div class="card-header bg-dark text-white"><span>Tags</span></div>
<div class="card-body">
{% if tags.art_style %}<span class="badge bg-info">{{ tags.art_style }}</span>{% endif %}
{% if tags.base_model %}<span class="badge bg-primary">{{ tags.base_model }}</span>{% endif %}
{% if ckpt.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if ckpt.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -235,184 +248,13 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
} catch (err) {}
}, 1500);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
title="${charName}">
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
</div>`;
gallery.insertBefore(col, gallery.firstChild);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
// Phase 1: submit all jobs immediately
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const fd = new FormData();
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
const resp = await fetch(form.getAttribute('action'), {
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
// Phase 2: poll all in parallel
batchLabel.textContent = `0 / ${pending.length} complete`;
let done = 0;
const total = pending.length;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping after current submissions...';
});
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, 'Endless');
}
};
// JSON Editor
initJsonEditor('{{ url_for("save_checkpoint_json", slug=ckpt.slug) }}');
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_checkpoint_json", slug=ckpt.slug) }}'
});
});
</script>
{% endblock %}

View File

@@ -57,8 +57,8 @@
</div>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
<button class="btn btn-sm btn-outline-danger py-0 px-1 ms-1 resource-delete-btn" title="Delete"
data-category="checkpoints" data-slug="{{ ckpt.slug }}" data-name="{{ ckpt.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 ms-1 resource-delete-btn" title="Delete"
data-category="checkpoints" data-slug="{{ ckpt.slug }}" data-name="{{ ckpt.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -271,114 +271,10 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
let currentJobId = null;
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
// Clicking any image with data-preview-path selects it into the preview pane
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') {
clearInterval(poll);
resolve(data);
} else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll);
reject(new Error(data.error || 'Job failed'));
} else if (data.status === 'processing') {
progressLabel.textContent = 'Generating…';
} else {
progressLabel.textContent = 'Queued…';
}
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
currentJobId = data.job_id;
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(currentJobId);
currentJobId = null;
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
}
updateSeedFromResult(jobResult.result);
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
}
};
});
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({});
});
</script>
{% endblock %}

View File

@@ -122,6 +122,19 @@
</div>
</div>
</div>
{% set tags = detailer.data.tags if detailer.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4">
<div class="card-header bg-dark text-white"><span>Tags</span></div>
<div class="card-body">
{% if tags.associated_resource %}<span class="badge bg-info">{{ tags.associated_resource }}</span>{% endif %}
{% if tags.adetailer_targets %}<span class="badge bg-primary">{{ tags.adetailer_targets | join(', ') }}</span>{% endif %}
{% if detailer.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if detailer.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -265,188 +278,13 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
const actionSelect = document.getElementById('action_select');
charSelect.addEventListener('change', () => {
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action_slug', actionSelect.value);
fd.append('extra_positive', document.getElementById('extra_positive').value);
fd.append('extra_negative', document.getElementById('extra_negative').value);
fd.append('action', 'preview');
try {
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping…';
});
initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_detailer_json", slug=detailer.slug) }}'
});
});
</script>
{% endblock %}

View File

@@ -73,8 +73,8 @@
{% set lora_name = detailer.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ detailer.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="detailers" data-slug="{{ detailer.slug }}" data-name="{{ detailer.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="detailers" data-slug="{{ detailer.slug }}" data-name="{{ detailer.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -332,10 +332,10 @@
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Generator</a>
{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-2"
<button class="btn btn-sm btn-danger py-0 px-2"
title="Delete"
onclick='event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})'>
🗑
<img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;">
</button>
</div>
</div>

View File

@@ -84,8 +84,8 @@
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="characters" data-slug="{{ char.slug }}" data-name="{{ char.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="characters" data-slug="{{ char.slug }}" data-name="{{ char.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -217,192 +217,8 @@
document.getElementById('rdm-name').textContent = name;
resourceDeleteModal.show();
}
async function confirmResourceDelete(mode) {
resourceDeleteModal.hide();
try {
const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mode}),
});
const data = await res.json();
if (data.status === 'ok') {
const card = document.getElementById(`card-${_rdmSlug}`);
if (card) card.remove();
} else {
alert('Delete failed: ' + (data.error || 'unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e);
}
}
function regenerateTags(category, slug) {
const btn = document.getElementById('regenerate-tags-btn');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating…';
fetch(`/api/${category}/${slug}/regenerate_tags`, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
.then(({ok, data}) => {
if (ok && data.success) {
location.reload();
} else {
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = origText;
}
})
.catch(err => {
alert('Regeneration failed: ' + err);
btn.disabled = false;
btn.innerHTML = origText;
});
}
function initJsonEditor(saveUrl) {
const jsonModal = document.getElementById('jsonEditorModal');
if (!jsonModal) return;
const textarea = document.getElementById('json-editor-textarea');
const errBox = document.getElementById('json-editor-error');
const simplePanel = document.getElementById('json-simple-panel');
const advancedPanel = document.getElementById('json-advanced-panel');
const simpleTab = document.getElementById('json-simple-tab');
const advancedTab = document.getElementById('json-advanced-tab');
let activeTab = 'simple';
function buildSimpleForm(data) {
simplePanel.innerHTML = '';
for (const [key, value] of Object.entries(data)) {
const row = document.createElement('div');
row.className = 'row mb-2 align-items-start';
const labelCol = document.createElement('div');
labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1';
labelCol.textContent = key.replace(/_/g, ' ');
const inputCol = document.createElement('div');
inputCol.className = 'col-sm-9';
let el;
if (typeof value === 'boolean') {
const wrap = document.createElement('div');
wrap.className = 'form-check mt-2';
el = document.createElement('input');
el.type = 'checkbox';
el.className = 'form-check-input';
el.checked = value;
el.dataset.dtype = 'boolean';
wrap.appendChild(el);
inputCol.appendChild(wrap);
} else if (typeof value === 'number') {
el = document.createElement('input');
el.type = 'number';
el.step = 'any';
el.className = 'form-control form-control-sm';
el.value = value;
el.dataset.dtype = 'number';
inputCol.appendChild(el);
} else if (typeof value === 'string') {
if (value.length > 80) {
el = document.createElement('textarea');
el.className = 'form-control form-control-sm';
el.rows = 3;
} else {
el = document.createElement('input');
el.type = 'text';
el.className = 'form-control form-control-sm';
}
el.value = value;
el.dataset.dtype = 'string';
inputCol.appendChild(el);
} else {
el = document.createElement('textarea');
el.className = 'form-control form-control-sm font-monospace';
const lines = JSON.stringify(value, null, 2).split('\n');
el.rows = Math.min(10, lines.length + 1);
el.value = JSON.stringify(value, null, 2);
el.dataset.dtype = 'json';
inputCol.appendChild(el);
}
el.dataset.key = key;
row.appendChild(labelCol);
row.appendChild(inputCol);
simplePanel.appendChild(row);
}
}
function readSimpleForm() {
const result = {};
simplePanel.querySelectorAll('[data-key]').forEach(el => {
const key = el.dataset.key;
const dtype = el.dataset.dtype;
if (dtype === 'boolean') result[key] = el.checked;
else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; }
else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } }
else result[key] = el.value;
});
return result;
}
simpleTab.addEventListener('click', () => {
errBox.classList.add('d-none');
let data;
try { data = JSON.parse(textarea.value); }
catch (e) { errBox.textContent = 'Cannot switch: invalid JSON — ' + e.message; errBox.classList.remove('d-none'); return; }
buildSimpleForm(data);
simplePanel.classList.remove('d-none');
advancedPanel.classList.add('d-none');
simpleTab.classList.add('active');
advancedTab.classList.remove('active');
activeTab = 'simple';
});
advancedTab.addEventListener('click', () => {
textarea.value = JSON.stringify(readSimpleForm(), null, 2);
advancedPanel.classList.remove('d-none');
simplePanel.classList.add('d-none');
advancedTab.classList.add('active');
simpleTab.classList.remove('active');
activeTab = 'advanced';
});
jsonModal.addEventListener('show.bs.modal', () => {
const raw = document.getElementById('json-raw-data').textContent;
let data;
try { data = JSON.parse(raw); } catch { data = {}; }
buildSimpleForm(data);
textarea.value = JSON.stringify(data, null, 2);
simplePanel.classList.remove('d-none');
advancedPanel.classList.add('d-none');
simpleTab.classList.add('active');
advancedTab.classList.remove('active');
activeTab = 'simple';
errBox.classList.add('d-none');
});
document.getElementById('json-save-btn').addEventListener('click', async () => {
errBox.classList.add('d-none');
let parsed;
if (activeTab === 'simple') {
parsed = readSimpleForm();
} else {
try { parsed = JSON.parse(textarea.value); }
catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; }
}
const fd = new FormData();
fd.append('json_data', JSON.stringify(parsed));
const resp = await fetch(saveUrl, { method: 'POST', body: fd });
const result = await resp.json();
if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); }
else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); }
});
}
</script>
<script src="/static/js/layout-utils.js"></script>
<script>
// ---- Service status indicators ----
(function () {

View File

@@ -277,90 +277,12 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewCardHeader = document.getElementById('preview-card-header');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
else progressLabel.textContent = 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; }
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
};
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
jsonEditorUrl: '{{ url_for("save_look_json", slug=look.slug) }}'
});
function showImage(src) {
if (src) document.getElementById('modalImage').src = src;
}
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');
});
</script>
{% endblock %}

View File

@@ -73,8 +73,8 @@
{% set lora_name = look.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ look.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="looks" data-slug="{{ look.slug }}" data-name="{{ look.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="looks" data-slug="{{ look.slug }}" data-name="{{ look.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -275,209 +275,13 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
// Clicking any image with data-preview-path selects it into the preview pane
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') {
clearInterval(poll);
resolve(data);
} else if (data.status === 'failed' || data.status === 'removed') {
clearInterval(poll);
reject(new Error(data.error || 'Job failed'));
} else if (data.status === 'processing') {
progressLabel.textContent = 'Generating…';
} else {
progressLabel.textContent = 'Queued…';
}
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%';
progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) {
console.error(err);
alert('Generation failed: ' + err.message);
} finally {
progressContainer.classList.add('d-none');
progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
// Batch: Generate All Characters
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
// Phase 1: submit all jobs immediately
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
// Phase 2: poll all in parallel
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) {
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}
}).catch(err => {
done++;
console.error(`Failed for ${char.name}:`, err);
})
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping…';
});
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_outfit_json", slug=outfit.slug) }}'
});
});
</script>
{% endblock %}

View File

@@ -74,8 +74,8 @@
{% set lora_name = outfit.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ outfit.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="outfits" data-slug="{{ outfit.slug }}" data-name="{{ outfit.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="outfits" data-slug="{{ outfit.slug }}" data-name="{{ outfit.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -113,20 +113,19 @@
</div>
<!-- Selected Preview -->
<div class="card mb-3" id="preview-pane" {% if not preview_path %}style="display:none"{% endif %}>
<div class="card-header d-flex justify-content-between align-items-center py-1">
<small class="fw-semibold">Selected Preview</small>
</div>
<div class="card-body p-1">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_path) if preview_path else '' }}"
class="img-fluid rounded" alt="Preview">
</div>
<div class="card-footer p-2">
<form action="{{ url_for('replace_preset_cover_from_preview', slug=preset.slug) }}" method="post">
<div class="card mb-4 {% if preview_path %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
<div class="card-header {% if preview_path %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
<span>Selected Preview</span>
<form action="{{ url_for('replace_preset_cover_from_preview', slug=preset.slug) }}" method="post" class="m-0" id="replace-cover-form">
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_path or '' }}">
<button type="submit" class="btn btn-sm btn-warning w-100">Set as Cover</button>
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_path %}disabled{% endif %}>Replace Cover</button>
</form>
</div>
<div class="card-body p-0">
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;">
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_path) if preview_path else '' }}" alt="Preview" class="img-fluid">
</div>
</div>
</div>
</div>
@@ -304,119 +303,12 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
// Job polling
let currentJobId = null;
document.getElementById('generate-form').addEventListener('submit', function(e) {
e.preventDefault();
const btn = e.submitter;
const actionVal = btn.value;
const formData = new FormData(this);
formData.set('action', actionVal);
btn.disabled = true;
btn.textContent = 'Generating...';
fetch(this.getAttribute('action'), {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData
})
.then(r => r.json())
.then(data => {
if (data.job_id) {
currentJobId = data.job_id;
pollJob(currentJobId, btn, actionVal);
} else {
btn.disabled = false;
btn.textContent = btn.dataset.label || 'Generate Preview';
alert('Error: ' + (data.error || 'Unknown error'));
}
})
.catch(() => { btn.disabled = false; });
});
function pollJob(jobId, btn, actionVal) {
fetch('/api/queue/' + jobId + '/status')
.then(r => r.json())
.then(data => {
if (data.status === 'done' && data.result) {
btn.disabled = false;
btn.textContent = btn.getAttribute('data-orig') || btn.textContent.replace('Generating...', 'Generate Preview');
// Add to gallery
const img = document.createElement('img');
img.src = data.result.image_url;
img.className = 'img-fluid rounded';
img.style.cursor = 'pointer';
img.dataset.previewPath = data.result.relative_path;
img.addEventListener('click', () => selectPreview(data.result.relative_path, img.src));
const col = document.createElement('div');
col.className = 'col-4 col-md-3';
col.appendChild(img);
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
selectPreview(data.result.relative_path, data.result.image_url);
updateSeedFromResult(data.result);
} else if (data.status === 'failed') {
btn.disabled = false;
btn.textContent = 'Generate Preview';
alert('Generation failed: ' + (data.error || 'Unknown error'));
} else {
setTimeout(() => pollJob(jobId, btn, actionVal), 1500);
}
})
.catch(() => setTimeout(() => pollJob(jobId, btn, actionVal), 3000));
}
function selectPreview(relativePath, imageUrl) {
document.getElementById('preview-path').value = relativePath;
document.getElementById('preview-img').src = imageUrl;
document.getElementById('preview-pane').style.display = '';
}
function showImage(src) {
if (src) document.getElementById('modalImage').src = src;
}
// Delegate click on generated images
document.addEventListener('click', function(e) {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
const img = document.createElement('img');
img.src = jobResult.result.image_url;
img.className = 'img-fluid rounded';
img.style.cursor = 'pointer';
img.dataset.previewPath = jobResult.result.relative_path;
img.addEventListener('click', () => selectPreview(jobResult.result.relative_path, img.src));
const col = document.createElement('div');
col.className = 'col-4 col-md-3';
col.appendChild(img);
document.getElementById('generated-images').prepend(col);
document.getElementById('no-images-msg')?.classList.add('d-none');
}
};
// Resolution preset buttons
document.querySelectorAll('.res-preset').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('res-width').value = btn.dataset.w;
document.getElementById('res-height').value = btn.dataset.h;
document.querySelectorAll('.res-preset').forEach(b => {
b.classList.remove('btn-secondary');
b.classList.add('btn-outline-secondary');
});
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-secondary');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
jsonEditorUrl: "{{ url_for('save_preset_json', slug=preset.slug) }}"
});
});
// JSON editor
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
</script>
{% endblock %}

View File

@@ -111,6 +111,18 @@
</div>
</div>
</div>
{% set tags = scene.data.tags if scene.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4">
<div class="card-header bg-dark text-white"><span>Tags</span></div>
<div class="card-body">
{% if tags.scene_type %}<span class="badge bg-info">{{ tags.scene_type }}</span>{% endif %}
{% if scene.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if scene.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -271,192 +283,13 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative preview-img-wrapper">
<img src="${imageUrl}" class="img-fluid rounded preview-img"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
// Add click handler for gallery navigation
const img = col.querySelector('.preview-img');
img.addEventListener('click', () => {
const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src);
const index = allImages.indexOf(imageUrl);
openGallery(allImages, index);
});
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping…';
});
initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_scene_json", slug=scene.slug) }}'
});
});
</script>
{% endblock %}

View File

@@ -71,8 +71,8 @@
{% set lora_name = scene.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ scene.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="scenes" data-slug="{{ scene.slug }}" data-name="{{ scene.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="scenes" data-slug="{{ scene.slug }}" data-name="{{ scene.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>

View File

@@ -111,6 +111,18 @@
</div>
</div>
</div>
{% set tags = style.data.tags if style.data.tags is mapping else {} %}
{% if tags %}
<div class="card mb-4">
<div class="card-header bg-dark text-white"><span>Tags</span></div>
<div class="card-body">
{% if tags.style_type %}<span class="badge bg-info">{{ tags.style_type }}</span>{% endif %}
{% if style.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
{% if style.is_favourite %}<span class="badge bg-warning text-dark">&#9733; Favourite</span>{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="col-md-8">
@@ -263,185 +275,20 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/detail-common.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Favourite toggle
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await resp.json();
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '&#9733;' : '&#9734;';
});
});
const form = document.getElementById('generate-form');
const progressBar = document.getElementById('progress-bar');
const progressContainer = document.getElementById('progress-container');
const progressLabel = document.getElementById('progress-label');
const previewCard = document.getElementById('preview-card');
const previewImg = document.getElementById('preview-img');
const previewPath = document.getElementById('preview-path');
const replaceBtn = document.getElementById('replace-cover-btn');
const previewHeader = document.getElementById('preview-card-header');
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
function selectPreview(relativePath, imageUrl) {
if (!relativePath) return;
previewImg.src = imageUrl;
previewPath.value = relativePath;
replaceBtn.disabled = false;
previewCard.classList.remove('d-none');
previewHeader.classList.replace('bg-secondary', 'bg-success');
previewCard.classList.replace('border-secondary', 'border-success');
}
document.addEventListener('click', e => {
const img = e.target.closest('img[data-preview-path]');
if (img) selectPreview(img.dataset.previewPath, img.src);
});
async function waitForJob(jobId) {
return new Promise((resolve, reject) => {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/queue/${jobId}/status`);
const data = await resp.json();
if (data.status === 'done') { clearInterval(poll); resolve(data); }
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
} catch (err) { console.error('Poll error:', err); }
}, 1500);
});
}
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (!submitter || submitter.value !== 'preview') return;
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'preview');
progressContainer.classList.remove('d-none');
progressBar.style.width = '100%'; progressBar.textContent = '';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressLabel.textContent = 'Queuing…';
try {
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.error) { alert('Error: ' + data.error); return; }
progressLabel.textContent = 'Queued…';
const jobResult = await waitForJob(data.job_id);
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
updateSeedFromResult(jobResult.result);
} catch (err) { console.error(err); alert('Generation failed: ' + err.message); }
finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); }
});
// Endless mode callback
window._onEndlessResult = function(jobResult) {
if (jobResult.result?.image_url) {
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
}
};
const allCharacters = [
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
{% endfor %}
];
let stopBatch = false;
const generateAllBtn = document.getElementById('generate-all-btn');
const stopAllBtn = document.getElementById('stop-all-btn');
const batchProgress = document.getElementById('batch-progress');
const batchLabel = document.getElementById('batch-label');
const batchBar = document.getElementById('batch-bar');
function addToPreviewGallery(imageUrl, relativePath, charName) {
const gallery = document.getElementById('preview-gallery');
const placeholder = document.getElementById('gallery-empty');
if (placeholder) placeholder.remove();
const col = document.createElement('div');
col.className = 'col';
col.innerHTML = `<div class="position-relative">
<img src="${imageUrl}" class="img-fluid rounded"
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
data-preview-path="${relativePath}"
title="${charName}">
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
</div>`;
gallery.insertBefore(col, gallery.firstChild);
const badge = document.querySelector('#previews-tab .badge');
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
}
generateAllBtn.addEventListener('click', async () => {
if (allCharacters.length === 0) { alert('No characters available.'); return; }
stopBatch = false;
generateAllBtn.disabled = true;
stopAllBtn.classList.remove('d-none');
batchProgress.classList.remove('d-none');
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
const genForm = document.getElementById('generate-form');
const formAction = genForm.getAttribute('action');
batchLabel.textContent = 'Queuing all characters…';
const pending = [];
for (const char of allCharacters) {
if (stopBatch) break;
const fd = new FormData();
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
fd.append('character_slug', char.slug);
fd.append('action', 'preview');
try {
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
if (!data.error) pending.push({ char, jobId: data.job_id });
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
}
batchBar.style.width = '0%';
let done = 0;
const total = pending.length;
batchLabel.textContent = `0 / ${total} complete`;
await Promise.all(pending.map(({ char, jobId }) =>
waitForJob(jobId).then(result => {
done++;
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
batchLabel.textContent = `${done} / ${total} complete`;
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
));
batchBar.style.width = '100%';
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
generateAllBtn.disabled = false;
stopAllBtn.classList.add('d-none');
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
});
stopAllBtn.addEventListener('click', () => {
stopBatch = true;
stopAllBtn.classList.add('d-none');
batchLabel.textContent = 'Stopping…';
});
initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
// Register preview gallery for navigation
registerGallery('#preview-gallery', '.preview-img');
document.addEventListener('DOMContentLoaded', () => {
initDetailPage({
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
jsonEditorUrl: '{{ url_for("save_style_json", slug=style.slug) }}'
});
// Character-context toggle (style-specific)
const charSelect = document.getElementById('character_select');
const charContext = document.getElementById('character-context');
charSelect.addEventListener('change', () => {
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
});
});
</script>
{% endblock %}

View File

@@ -71,8 +71,8 @@
{% set lora_name = style.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
<small class="text-muted text-truncate" title="{{ style.data.lora.lora_name }}">{{ lora_name }}</small>
{% else %}<span></span>{% endif %}
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="styles" data-slug="{{ style.slug }}" data-name="{{ style.name | e }}">🗑</button>
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
data-category="styles" data-slug="{{ style.slug }}" data-name="{{ style.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
</div>
</div>
</div>