- Add extra positive/negative prompt textareas to all 9 detail pages with session persistence - Add Endless generation button to all detail pages (continuous preview generation until stopped) - Default character selector to "Random Character" on all secondary detail pages - Fix queue clear endpoint (remove spurious auth check) - Refactor app.py into routes/ and services/ modules - Update CLAUDE.md with new architecture documentation - Various data file updates and cleanup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
28 KiB
GAZE — Character Browser: LLM Development Guide
What This Project Is
GAZE is a Flask web app for managing AI image generation assets and generating images via ComfyUI. It is a personal creative tool for organizing characters, outfits, actions, styles, scenes, and detailers — all of which map to Stable Diffusion LoRAs and prompt fragments — and generating images by wiring those assets into a ComfyUI workflow at runtime.
The app is deployed locally, connects to a local ComfyUI instance at http://127.0.0.1:8188, and uses SQLite for persistence. LoRA and model files live on /mnt/alexander/AITools/Image Models/.
Architecture
File Structure
app.py # ~186 lines: Flask init, config, logging, route registration, startup/migrations
models.py # SQLAlchemy models only
comfy_workflow.json # ComfyUI workflow template with placeholder strings
utils.py # Pure constants + helpers (no Flask/DB deps)
services/
__init__.py
comfyui.py # ComfyUI HTTP client (queue_prompt, get_history, get_image)
workflow.py # Workflow building (_prepare_workflow, _apply_checkpoint_settings)
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
job_queue.py # Background job queue (_enqueue_job, _make_finalize, worker thread)
file_io.py # LoRA/checkpoint scanning, file helpers
routes/
__init__.py # register_routes(app) — imports and calls all route modules
characters.py # Character CRUD + generation + outfit management
outfits.py # Outfit routes
actions.py # Action routes
styles.py # Style routes
scenes.py # Scene routes
detailers.py # Detailer routes
checkpoints.py # Checkpoint routes
looks.py # Look routes
presets.py # Preset routes
generator.py # Generator mix-and-match page
gallery.py # Gallery browsing + image/resource deletion
settings.py # Settings page + status APIs + context processors
strengths.py # Strengths gallery system
transfer.py # Resource transfer system
queue_api.py # /api/queue/* endpoints
Dependency Graph
app.py
├── models.py (unchanged)
├── utils.py (no deps except stdlib)
├── services/
│ ├── comfyui.py ← utils (for config)
│ ├── prompts.py ← utils, models
│ ├── workflow.py ← prompts, utils, models
│ ├── llm.py ← mcp (for tool calls)
│ ├── mcp.py ← (stdlib only: subprocess, os)
│ ├── sync.py ← models, utils
│ ├── job_queue.py ← comfyui, models
│ └── file_io.py ← models, utils
└── routes/
├── All route modules ← services/*, utils, models
└── (routes never import from other routes)
No circular imports: routes → services → utils/models. Services never import routes. Utils never imports services.
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.
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.
Models: Character, Look, Outfit, Action, Style, Scene, Detailer, Checkpoint, Settings
All category models (except Settings and Checkpoint) share this pattern:
{entity}_id— canonical ID (from JSON, often matches filename without extension)slug— URL-safe version of the ID (alphanumeric + underscores only, viare.sub(r'[^a-zA-Z0-9_]', '', id))name— display namefilename— original JSON filenamedata— full JSON blob (SQLAlchemy JSON column)default_fields— list ofsection::keystrings saved as the user's preferred prompt fieldsimage_path— relative path understatic/uploads/
Data Flow: JSON → DB → Prompt → ComfyUI
- JSON files in
data/{characters,clothing,actions,styles,scenes,detailers,looks}/are loaded bysync_*()functions into SQLite. - At generation time,
build_prompt(data, selected_fields, default_fields, active_outfit)converts the character JSON blob into{"main": ..., "face": ..., "hand": ...}prompt strings. _prepare_workflow(workflow, character, prompts, ...)wires prompts and LoRAs into the loadedcomfy_workflow.json.queue_prompt(workflow, client_id)POSTs the workflow to ComfyUI's/promptendpoint.- The app polls
get_history(prompt_id)and retrieves the image viaget_image(filename, subfolder, type).
ComfyUI Workflow Node Map
The workflow (comfy_workflow.json) uses string node IDs. These are the critical nodes:
| Node | Role |
|---|---|
3 |
Main KSampler |
4 |
Checkpoint loader |
5 |
Empty latent (width/height) |
6 |
Positive prompt — contains {{POSITIVE_PROMPT}} placeholder |
7 |
Negative prompt |
8 |
VAE decode |
9 |
Save image |
11 |
Face ADetailer |
13 |
Hand ADetailer |
14 |
Face detailer prompt — contains {{FACE_PROMPT}} placeholder |
15 |
Hand detailer prompt — contains {{HAND_PROMPT}} placeholder |
16 |
Character LoRA (or Look LoRA when a Look is active) |
17 |
Outfit LoRA |
18 |
Action LoRA |
19 |
Style / Detailer / Scene LoRA (priority: style > detailer > scene) |
LoRA nodes chain: 4 → 16 → 17 → 18 → 19. Unused LoRA nodes are bypassed by pointing model_source/clip_source directly to the prior node. All model/clip consumers (nodes 3, 6, 7, 11, 13, 14, 15) are wired to the final model_source/clip_source at the end of _prepare_workflow.
Key Functions by Module
utils.py — Constants and Pure Helpers
_IDENTITY_KEYS/_WARDROBE_KEYS— Lists of canonical field names for theidentityandwardrobesections. Used by_ensure_character_fields().ALLOWED_EXTENSIONS— Permitted upload file extensions._LORA_DEFAULTS— Default LoRA directory paths per category.parse_orientation(orientation_str)— Converts orientation codes (1F,2F,1M1F, etc.) into Danbooru tags._resolve_lora_weight(lora_data)— Extracts and validates LoRA weight from a lora data dict.allowed_file(filename)— Checks file extension againstALLOWED_EXTENSIONS.
services/prompts.py — Prompt Building
build_prompt(data, selected_fields, default_fields, active_outfit)— Converts a character (or combined) data dict into{"main", "face", "hand"}prompt strings. Field selection priority:selected_fields→default_fields→ select all (fallback). Fields are addressed as"section::key"strings (e.g."identity::hair","wardrobe::top"). Characters support a nested wardrobe format wherewardrobeis a dict of outfit names → outfit dicts.build_extras_prompt(actions, outfits, scenes, styles, detailers)— Used by the Generator page. Combines prompt text from all checked items across categories into a single string._cross_dedup_prompts(positive, negative)— Cross-deduplicates tags between positive and negative prompt strings. Equal counts cancel completely; excess on one side is retained._resolve_character(character_slug)— Returns aCharacterORM object for a given slug string. Handles"__random__"sentinel._ensure_character_fields(character, selected_fields, ...)— Mutatesselected_fieldsin place, appending populated identity/wardrobe keys. Called in every secondary-category generate route after_resolve_character()._append_background(prompts, character=None)— Appends"<primary_color> simple background"tag toprompts['main'].
services/workflow.py — Workflow Wiring
_prepare_workflow(workflow, character, prompts, ...)— Core workflow wiring function. Replaces prompt placeholders, chains LoRA nodes dynamically, randomises seeds, applies checkpoint settings, runs cross-dedup as the final step._apply_checkpoint_settings(workflow, ckpt_data)— Applies checkpoint-specific sampler/prompt/VAE settings._get_default_checkpoint()— Returns(checkpoint_path, checkpoint_data)from session, database Settings, or workflow file fallback._log_workflow_prompts(label, workflow)— Logs the fully assembled workflow prompts in a readable block.
services/job_queue.py — Background Job Queue
_enqueue_job(label, workflow, finalize_fn)— Adds a generation job to the queue._make_finalize(category, slug, db_model_class=None, action=None)— Factory returning a callback that retrieves the generated image from ComfyUI, saves it, and optionally updates the DB cover image._prune_job_history(max_age_seconds=3600)— Removes old terminal-state jobs from memory.init_queue_worker(flask_app)— Stores the app reference and starts the worker thread.
services/comfyui.py — ComfyUI HTTP Client
queue_prompt(prompt_workflow, client_id)— POSTs workflow to ComfyUI's/promptendpoint.get_history(prompt_id)— Polls ComfyUI for job completion.get_image(filename, subfolder, folder_type)— Retrieves generated image bytes._ensure_checkpoint_loaded(checkpoint_path)— Forces ComfyUI to load a specific checkpoint.
services/llm.py — LLM Integration
call_llm(prompt, system_prompt)— OpenAI-compatible chat completion supporting OpenRouter (cloud) and Ollama/LMStudio (local). Implements a tool-calling loop (up to 10 turns) usingDANBOORU_TOOLSvia MCP Docker container.load_prompt(filename)— Loads system prompt text fromdata/prompts/.call_mcp_tool()— Synchronous wrapper for MCP tool calls.
services/sync.py — Data Synchronization
sync_characters(),sync_outfits(),sync_actions(), etc. — Load JSON files fromdata/directories into SQLite. One function per category._resolve_preset_entity(type, id)/_resolve_preset_fields(preset_data)— Preset resolution helpers.
services/file_io.py — File & DB Helpers
get_available_loras(category)— Scans filesystem for available LoRA files in a category.get_available_checkpoints()— Scans checkpoint directories._count_look_assignments()/_count_outfit_lora_assignments()— DB aggregate queries.
services/mcp.py — MCP/Docker Lifecycle
ensure_mcp_server_running()— Ensures the danbooru-mcp Docker container is running.ensure_character_mcp_server_running()— Ensures the character-mcp Docker container is running.
Route-local Helpers
Some helpers are defined inside a route module's register_routes() since they're only used by routes in that file:
routes/scenes.py:_queue_scene_generation()— scene-specific workflow builderroutes/detailers.py:_queue_detailer_generation()— detailer-specific generation helperroutes/styles.py:_build_style_workflow()— style-specific workflow builderroutes/checkpoints.py:_build_checkpoint_workflow()— checkpoint-specific workflow builderroutes/strengths.py:_build_strengths_prompts(),_prepare_strengths_workflow()— strengths gallery helpersroutes/transfer.py:_create_minimal_template()— transfer template builderroutes/gallery.py:_scan_gallery_images(),_enrich_with_names(),_parse_comfy_png_metadata()
JSON Data Schemas
Character (data/characters/*.json)
{
"character_id": "tifa_lockhart",
"character_name": "Tifa Lockhart",
"identity": { "base_specs": "", "hair": "", "eyes": "", "hands": "", "arms": "", "torso": "", "pelvis": "", "legs": "", "feet": "", "extra": "" },
"defaults": { "expression": "", "pose": "", "scene": "" },
"wardrobe": {
"default": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "gloves": "", "accessories": "" }
},
"styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" },
"lora": { "lora_name": "Illustrious/Looks/tifa.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
"tags": [],
"participants": { "orientation": "1F", "solo_focus": "true" }
}
participants is optional; when absent, (solo:1.2) is injected. orientation is parsed by parse_orientation() into Danbooru tags (1girl, hetero, etc.).
Outfit (data/clothing/*.json)
{
"outfit_id": "french_maid_01",
"outfit_name": "French Maid",
"wardrobe": { "full_body": "", "headwear": "", "top": "", "bottom": "", "legwear": "", "footwear": "", "hands": "", "accessories": "" },
"lora": { "lora_name": "Illustrious/Clothing/maid.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
"tags": []
}
Action (data/actions/*.json)
{
"action_id": "sitting",
"action_name": "Sitting",
"action": { "full_body": "", "additional": "", "head": "", "eyes": "", "arms": "", "hands": "" },
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
"tags": []
}
Scene (data/scenes/*.json)
{
"scene_id": "beach",
"scene_name": "Beach",
"scene": { "background": "", "foreground": "", "furniture": "", "colors": "", "lighting": "", "theme": "" },
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
"tags": []
}
Style (data/styles/*.json)
{
"style_id": "watercolor",
"style_name": "Watercolor",
"style": { "artist_name": "", "artistic_style": "" },
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }
}
Detailer (data/detailers/*.json)
{
"detailer_id": "detailed_skin",
"detailer_name": "Detailed Skin",
"prompt": ["detailed skin", "pores"],
"focus": { "face": true, "hands": true },
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }
}
Look (data/looks/*.json)
{
"look_id": "tifa_casual",
"look_name": "Tifa Casual",
"character_id": "tifa_lockhart",
"positive": "casual clothes, jeans",
"negative": "revealing",
"lora": { "lora_name": "Illustrious/Looks/tifa_casual.safetensors", "lora_weight": 0.85, "lora_triggers": "" },
"tags": []
}
Looks occupy LoRA node 16, overriding the character's own LoRA. The Look's negative is prepended to the workflow's negative prompt.
Checkpoint (data/checkpoints/*.json)
{
"checkpoint_path": "Illustrious/model.safetensors",
"checkpoint_name": "Model Display Name",
"base_positive": "anime",
"base_negative": "text, logo",
"steps": 25,
"cfg": 5,
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"vae": "integrated"
}
Checkpoint JSONs are keyed by checkpoint_path. If no JSON exists for a discovered model file, _default_checkpoint_data() provides defaults.
URL Routes
Characters
GET /— character gallery (index)GET /character/<slug>— character detail with generation UIPOST /character/<slug>/generate— queue generation (AJAX or form); returns{"job_id": ...}POST /character/<slug>/replace_cover_from_preview— promote preview to coverGET/POST /character/<slug>/edit— edit character dataPOST /character/<slug>/upload— upload cover imagePOST /character/<slug>/save_defaults— save default field selectionPOST /character/<slug>/outfit/switch|add|delete|rename— manage per-character wardrobe outfitsGET/POST /create— create new character (blank or LLM-generated)POST /rescan— sync DB from JSON files
Category Pattern (Outfits, Actions, Styles, Scenes, Detailers)
Each category follows the same URL pattern:
GET /<category>/— galleryGET /<category>/<slug>— detail + generation UIPOST /<category>/<slug>/generate— queue generation; returns{"job_id": ...}POST /<category>/<slug>/replace_cover_from_previewGET/POST /<category>/<slug>/editPOST /<category>/<slug>/uploadPOST /<category>/<slug>/save_defaultsPOST /<category>/<slug>/clone— duplicate entryPOST /<category>/<slug>/save_json— save raw JSON (from modal editor)POST /<category>/rescanPOST /<category>/bulk_create— LLM-generate entries from LoRA files on disk
Looks
GET /looks— galleryGET /look/<slug>— detailGET/POST /look/<slug>/editPOST /look/<slug>/generate— queue generation; returns{"job_id": ...}POST /look/<slug>/replace_cover_from_previewGET/POST /look/createPOST /looks/rescan
Generator (Mix & Match)
GET/POST /generator— freeform generator with multi-select accordion UIPOST /generator/preview_prompt— AJAX: preview composed prompt without generating
Checkpoints
GET /checkpoints— galleryGET /checkpoint/<slug>— detail + generation settings editorPOST /checkpoint/<slug>/save_jsonPOST /checkpoints/rescan
Job Queue API
All generation routes use the background job queue. Frontend polls:
GET /api/queue/<job_id>/status— returns{"status": "pending"|"running"|"done"|"failed", "result": {...}}
Image retrieval is handled server-side by the _make_finalize() callback; there are no separate client-facing finalize routes.
Utilities
POST /set_default_checkpoint— save default checkpoint to session and persist tocomfy_workflow.jsonGET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}— AJAX: list items without cover images (sorted by display name)POST /generate_missing— batch generate covers for all characters missing one (uses job queue)POST /clear_all_covers/clear_all_{outfit,action,scene,style,detailer,look,checkpoint}_coversGET /gallery— global image gallery browsingstatic/uploads/GET/POST /settings— LLM provider configurationPOST /resource/<category>/<slug>/delete— soft (JSON only) or hard (JSON + safetensors) delete
Frontend
- Bootstrap 5.3 (CDN). Custom styles in
static/style.css. - All templates extend
templates/layout.html. The base layout provides:{% block content %}— main page content{% block scripts %}— additional JS at end of body- 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)
- Context processors inject
all_checkpoints,default_checkpoint_path, andCOMFYUI_WS_URLinto every template. - 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>/statusevery ~1.5 seconds untilstatus == "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. - Batch generation (library pages): Uses a two-phase pattern:
- Queue phase: All jobs are submitted upfront via sequential fetch calls, collecting job IDs
- Poll phase: All jobs are polled concurrently via
Promise.all(), updating UI as each completes - Progress tracking: Displays currently processing items in real-time using a
Setto track active jobs - Sorting: All batch operations sort items by display
name(notfilename) for better UX
LLM Integration
System Prompts
Text files in data/prompts/ define JSON output schemas for LLM-generated entries:
character_system.txt— character JSON schemaoutfit_system.txt— outfit JSON schemaaction_system.txt,scene_system.txt,style_system.txt,detailer_system.txt,look_system.txt,checkpoint_system.txt
Used by: character/outfit/action/scene/style create forms, and bulk_create routes.
Danbooru MCP Tools
The LLM loop in call_llm() provides three tools via a Docker-based MCP server (danbooru-mcp:latest):
search_tags(query, limit, category)— prefix searchvalidate_tags(tags)— exact-match validationsuggest_tags(partial, limit, category)— autocomplete
The LLM uses these to verify and discover correct Danbooru-compatible tags for prompts.
All system prompts (character_system.txt, outfit_system.txt, action_system.txt, scene_system.txt, style_system.txt, detailer_system.txt, look_system.txt, checkpoint_system.txt) instruct the LLM to use these tools before finalising any tag values. checkpoint_system.txt applies them specifically to the base_positive and base_negative fields.
LoRA File Paths
LoRA filenames in JSON are stored as paths relative to ComfyUI's models/lora/ root:
| Category | Path prefix | Example |
|---|---|---|
| Character / Look | Illustrious/Looks/ |
Illustrious/Looks/tifa_v2.safetensors |
| Outfit | Illustrious/Clothing/ |
Illustrious/Clothing/maid.safetensors |
| Action | Illustrious/Poses/ |
Illustrious/Poses/sitting.safetensors |
| Style | Illustrious/Styles/ |
Illustrious/Styles/watercolor.safetensors |
| Detailer | Illustrious/Detailers/ |
Illustrious/Detailers/skin.safetensors |
| Scene | Illustrious/Backgrounds/ |
Illustrious/Backgrounds/beach.safetensors |
Checkpoint paths: Illustrious/<filename>.safetensors or Noob/<filename>.safetensors.
Absolute paths on disk:
- Checkpoints:
/mnt/alexander/AITools/Image Models/Stable-diffusion/{Illustrious,Noob}/ - LoRAs:
/mnt/alexander/AITools/Image Models/lora/Illustrious/{Looks,Clothing,Poses,Styles,Detailers,Backgrounds}/
Adding a New Category
To add a new content category (e.g. "Poses" as a separate concept from Actions), the pattern is:
- Model (
models.py): Add a new SQLAlchemy model with the standard fields. - Sync function (
services/sync.py): Addsync_newcategory()following the pattern ofsync_outfits(). - Data directory (
app.py): Addapp.config['NEWCATEGORY_DIR'] = 'data/newcategory'. - Routes (
routes/newcategory.py): Create a new route module with aregister_routes(app)function. Implement index, detail, edit, generate, replace_cover_from_preview, upload, save_defaults, clone, rescan routes. Followroutes/outfits.pyorroutes/scenes.pyexactly. - Route registration (
routes/__init__.py): Import and callnewcategory.register_routes(app). - Templates: Create
templates/newcategory/{index,detail,edit,create}.htmlextendinglayout.html. - Nav: Add link to navbar in
templates/layout.html. - Startup (
app.py): Import and callsync_newcategory()in thewith app.app_context()block. - Generator page: Add to
routes/generator.py,services/prompts.pybuild_extras_prompt(), andtemplates/generator.htmlaccordion.
Session Keys
The Flask filesystem session stores:
default_checkpoint— checkpoint_path string for the global defaultprefs_{slug}— selected_fields list for character detail pagepreview_{slug}— relative image path of last character previewprefs_outfit_{slug},preview_outfit_{slug},char_outfit_{slug}— outfit detail stateprefs_action_{slug},preview_action_{slug},char_action_{slug}— action detail stateprefs_scene_{slug},preview_scene_{slug},char_scene_{slug}— scene detail stateprefs_detailer_{slug},preview_detailer_{slug},char_detailer_{slug},action_detailer_{slug},extra_pos_detailer_{slug},extra_neg_detailer_{slug}— detailer detail state (selected fields, preview image, character, action LoRA, extra positive prompt, extra negative prompt)prefs_style_{slug},preview_style_{slug},char_style_{slug}— style detail stateprefs_look_{slug},preview_look_{slug}— look detail state
Running the App
Directly (development)
cd /mnt/alexander/Projects/character-browser
source venv/bin/activate
python app.py
The app runs in debug mode on port 5000 by default. ComfyUI must be running at http://127.0.0.1:8188.
The DB is initialised and all sync functions are called inside with app.app_context(): at the bottom of app.py before app.run().
Docker
docker compose up -d
The compose file (docker-compose.yml) runs two services:
danbooru-mcp— built fromhttps://git.liveaodh.com/aodhan/danbooru-mcp.git; the MCP tag-search container used bycall_llm().app— the Flask app, exposed on host port 5782 → container port 5000.
Key environment variables set by compose:
COMFYUI_URL=http://10.0.0.200:8188— points at ComfyUI on the Docker host network.SKIP_MCP_AUTOSTART=true— disables the app's built-in danbooru-mcp launch logic (compose manages it).
Volumes mounted into the app container:
./data,./static/uploads,./instance,./flask_session— persistent app data./Volumes/ImageModels:/ImageModels:ro— model files for checkpoint/LoRA scanning (requires Docker Desktop file sharing enabled for/Volumes/ImageModels)./var/run/docker.sock— Docker socket so the app can exec danbooru-mcp tool containers.
Common Pitfalls
- SQLAlchemy JSON mutation: After modifying a JSON column dict in place, always call
flag_modified(obj, "data")or the change won't be detected. - Dual write: Every edit route writes back to both the DB (
db.session.commit()) and the JSON file on disk. Both must be kept in sync. - Slug generation:
re.sub(r'[^a-zA-Z0-9_]', '', id)— note this removes hyphens and dots, not just replaces them. Character IDs likeyuna_(ff10)become slugyunaffx10. This is intentional. - Checkpoint slugs use underscore replacement:
re.sub(r'[^a-zA-Z0-9_]', '_', ...)(replaces with_, not removes) to preserve readability in paths. - LoRA chaining: If a LoRA node has no LoRA (name is empty/None), the node is skipped and
model_source/clip_sourcepass through unchanged. Do not set the node inputs for skipped nodes. - AJAX detection:
request.headers.get('X-Requested-With') == 'XMLHttpRequest'determines whether to return JSON or redirect. - Session must be marked modified for JSON responses: After setting session values in AJAX-responding routes, set
session.modified = True. - Detailer
promptis a list: Thepromptfield in detailer JSON is stored as a list of strings (e.g.["detailed skin", "pores"]), not a plain string. When merging intotagsforbuild_prompt, useextendfor lists andappendfor strings — never append the list object itself or", ".join()will fail on the nested list item. _make_finalizeaction semantics: Passaction=Nonewhen the route should always update the DB cover (e.g. batch generate, checkpoint generate). Passaction=request.form.get('action')for routes that support both "preview" (no DB update) and "replace" (update DB). The factory skips the DB write whenactionis truthy and not"replace".