Add semantic tagging, search, favourite/NSFW filtering, and LLM job queue
Replaces old list-format tags (which duplicated prompt content) with structured dict tags per category (origin_series, outfit_type, participants, style_type, scene_type, etc.). Tags are now purely organizational metadata — removed from the prompt pipeline entirely. Adds is_favourite and is_nsfw columns to all 8 resource models. Favourite is DB-only (user preference); NSFW is mirrored in JSON tags for rescan persistence. All library pages get filter controls and favourites-first sorting. Introduces a parallel LLM job queue (_enqueue_task + _llm_queue_worker) for background tag regeneration, with the same status polling UI as ComfyUI jobs. Fixes call_llm() to use has_request_context() fallback for background threads. Adds global search (/search) across resources and gallery images, with navbar search bar. Adds gallery image sidecar JSON for per-image favourite/NSFW metadata. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
98
CLAUDE.md
98
CLAUDE.md
@@ -46,6 +46,8 @@ routes/
|
||||
transfer.py # Resource transfer system
|
||||
queue_api.py # /api/queue/* endpoints
|
||||
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
|
||||
```
|
||||
|
||||
### Dependency Graph
|
||||
@@ -90,6 +92,8 @@ All category models (except Settings and Checkpoint) share this pattern:
|
||||
- `data` — full JSON blob (SQLAlchemy JSON column)
|
||||
- `default_fields` — list of `section::key` strings saved as the user's preferred prompt fields
|
||||
- `image_path` — relative path under `static/uploads/`
|
||||
- `is_favourite` — boolean (DB-only, not in JSON; toggled from detail pages)
|
||||
- `is_nsfw` — boolean (mirrored in both DB column and JSON `tags.nsfw`; synced on rescan)
|
||||
|
||||
### Data Flow: JSON → DB → Prompt → ComfyUI
|
||||
|
||||
@@ -156,10 +160,16 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
|
||||
|
||||
### `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.
|
||||
Two independent queues with separate worker threads:
|
||||
|
||||
- **ComfyUI queue** (`_job_queue` + `_queue_worker`): Image generation jobs.
|
||||
- **`_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.
|
||||
- **LLM queue** (`_llm_queue` + `_llm_queue_worker`): LLM task jobs (tag regeneration, bulk create with overwrite).
|
||||
- **`_enqueue_task(label, task_fn)`** — Adds an LLM task job. `task_fn` receives the job dict and runs inside `app.app_context()`.
|
||||
- **Shared**: Both queues share `_job_history` (for status lookup by job ID) and `_job_queue_lock`.
|
||||
- **`_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.
|
||||
- **`init_queue_worker(flask_app)`** — Stores the app reference and starts both worker threads.
|
||||
|
||||
### `services/comfyui.py` — ComfyUI HTTP Client
|
||||
|
||||
@@ -170,13 +180,14 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
|
||||
|
||||
### `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) using `DANBOORU_TOOLS` via MCP Docker container.
|
||||
- **`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) using `DANBOORU_TOOLS` via MCP Docker container. Safe to call from background threads (uses `has_request_context()` fallback for OpenRouter HTTP-Referer header).
|
||||
- **`load_prompt(filename)`** — Loads system prompt text from `data/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 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.
|
||||
- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers.
|
||||
|
||||
### `services/file_io.py` — File & DB Helpers
|
||||
@@ -203,7 +214,9 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
- `routes/checkpoints.py`: `_build_checkpoint_workflow()` — checkpoint-specific workflow builder
|
||||
- `routes/strengths.py`: `_build_strengths_prompts()`, `_prepare_strengths_workflow()` — strengths gallery helpers
|
||||
- `routes/transfer.py`: `_create_minimal_template()` — transfer template builder
|
||||
- `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()`
|
||||
- `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()`, `_write_sidecar()` — gallery image sidecar JSON I/O
|
||||
- `routes/regenerate.py`: Tag regeneration routes (single + category bulk + all), tag migration
|
||||
- `routes/search.py`: `_search_resources()`, `_search_images()` — global search across resources and gallery
|
||||
|
||||
---
|
||||
|
||||
@@ -221,7 +234,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
},
|
||||
"styles": { "aesthetic": "", "primary_color": "", "secondary_color": "", "tertiary_color": "" },
|
||||
"lora": { "lora_name": "Illustrious/Looks/tifa.safetensors", "lora_weight": 0.8, "lora_triggers": "" },
|
||||
"tags": [],
|
||||
"tags": { "origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false },
|
||||
"participants": { "orientation": "1F", "solo_focus": "true" }
|
||||
}
|
||||
```
|
||||
@@ -234,7 +247,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"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": []
|
||||
"tags": { "outfit_type": "Uniform", "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -245,7 +258,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"action_name": "Sitting",
|
||||
"action": { "full_body": "", "additional": "", "head": "", "eyes": "", "arms": "", "hands": "" },
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||
"tags": []
|
||||
"tags": { "participants": "1girl", "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -256,7 +269,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"scene_name": "Beach",
|
||||
"scene": { "background": "", "foreground": "", "furniture": "", "colors": "", "lighting": "", "theme": "" },
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||
"tags": []
|
||||
"tags": { "scene_type": "Outdoor", "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -266,7 +279,8 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"style_id": "watercolor",
|
||||
"style_name": "Watercolor",
|
||||
"style": { "artist_name": "", "artistic_style": "" },
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||
"tags": { "style_type": "Watercolor", "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -277,7 +291,8 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"detailer_name": "Detailed Skin",
|
||||
"prompt": ["detailed skin", "pores"],
|
||||
"focus": { "face": true, "hands": true },
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" }
|
||||
"lora": { "lora_name": "", "lora_weight": 1.0, "lora_triggers": "" },
|
||||
"tags": { "associated_resource": "skin", "adetailer_targets": ["face", "hands"], "nsfw": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -290,7 +305,7 @@ Some helpers are defined inside a route module's `register_routes()` since they'
|
||||
"positive": "casual clothes, jeans",
|
||||
"negative": "revealing",
|
||||
"lora": { "lora_name": "Illustrious/Looks/tifa_casual.safetensors", "lora_weight": 0.85, "lora_triggers": "" },
|
||||
"tags": []
|
||||
"tags": { "origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false }
|
||||
}
|
||||
```
|
||||
Looks occupy LoRA node 16, overriding the character's own LoRA. The Look's `negative` is prepended to the workflow's negative prompt.
|
||||
@@ -329,13 +344,14 @@ Checkpoint JSONs are keyed by `checkpoint_path`. If no JSON exists for a discove
|
||||
|
||||
### Category Pattern (Outfits, Actions, Styles, Scenes, Detailers)
|
||||
Each category follows the same URL pattern:
|
||||
- `GET /<category>/` — gallery
|
||||
- `GET /<category>/` — library with favourite/NSFW filter controls
|
||||
- `GET /<category>/<slug>` — detail + generation UI
|
||||
- `POST /<category>/<slug>/generate` — queue generation; returns `{"job_id": ...}`
|
||||
- `POST /<category>/<slug>/replace_cover_from_preview`
|
||||
- `GET/POST /<category>/<slug>/edit`
|
||||
- `POST /<category>/<slug>/upload`
|
||||
- `POST /<category>/<slug>/save_defaults`
|
||||
- `POST /<category>/<slug>/favourite` — toggle `is_favourite` (AJAX)
|
||||
- `POST /<category>/<slug>/clone` — duplicate entry
|
||||
- `POST /<category>/<slug>/save_json` — save raw JSON (from modal editor)
|
||||
- `POST /<category>/rescan`
|
||||
@@ -375,6 +391,19 @@ All generation routes use the background job queue. Frontend polls:
|
||||
|
||||
Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes.
|
||||
|
||||
### Search
|
||||
- `GET /search` — global search page; query params: `q` (search term), `category` (all/characters/outfits/etc.), `nsfw` (all/sfw/nsfw), `type` (all/resources/images)
|
||||
|
||||
### Tag Regeneration
|
||||
- `POST /api/<category>/<slug>/regenerate_tags` — single entity tag regeneration via LLM queue
|
||||
- `POST /admin/bulk_regenerate_tags/<category>` — queue LLM tag regeneration for all entities in a category
|
||||
- `POST /admin/bulk_regenerate_tags` — queue LLM tag regeneration for all resources across all categories
|
||||
- `POST /admin/migrate_tags` — convert old list-format tags to new dict format
|
||||
|
||||
### Gallery Image Metadata
|
||||
- `POST /gallery/image/favourite` — toggle favourite on a gallery image (writes sidecar JSON)
|
||||
- `POST /gallery/image/nsfw` — toggle NSFW on a gallery image (writes sidecar JSON)
|
||||
|
||||
### Utilities
|
||||
- `POST /set_default_checkpoint` — save default checkpoint to session and persist to `comfy_workflow.json`
|
||||
- `GET /get_missing_{characters,outfits,actions,scenes,styles,detailers,looks,checkpoints}` — AJAX: list items without cover images (sorted by display name)
|
||||
@@ -415,8 +444,10 @@ Text files in `data/prompts/` define JSON output schemas for LLM-generated entri
|
||||
- `character_system.txt` — character JSON schema
|
||||
- `outfit_system.txt` — outfit JSON schema
|
||||
- `action_system.txt`, `scene_system.txt`, `style_system.txt`, `detailer_system.txt`, `look_system.txt`, `checkpoint_system.txt`
|
||||
- `preset_system.txt` — preset JSON schema
|
||||
- `regenerate_tags_system.txt` — tag regeneration schema (all per-category tag structures)
|
||||
|
||||
Used by: character/outfit/action/scene/style create forms, and bulk_create routes.
|
||||
Used by: character/outfit/action/scene/style create forms, bulk_create routes, and tag regeneration. All system prompts include NSFW awareness preamble.
|
||||
|
||||
### Danbooru MCP Tools
|
||||
The LLM loop in `call_llm()` provides three tools via a Docker-based MCP server (`danbooru-mcp:latest`):
|
||||
@@ -430,6 +461,41 @@ All system prompts (`character_system.txt`, `outfit_system.txt`, `action_system.
|
||||
|
||||
---
|
||||
|
||||
## Tagging System
|
||||
|
||||
Tags are **semantic metadata** for organizing and filtering resources. They are **not injected into generation prompts** — tags are purely for the UI (search, filtering, categorization).
|
||||
|
||||
### Tag Schema Per Category
|
||||
|
||||
| Category | Tag fields | Example |
|
||||
|----------|-----------|---------|
|
||||
| Character | `origin_series`, `origin_type`, `nsfw` | `{"origin_series": "Final Fantasy VII", "origin_type": "game", "nsfw": false}` |
|
||||
| Look | `origin_series`, `origin_type`, `nsfw` | same as Character |
|
||||
| Outfit | `outfit_type`, `nsfw` | `{"outfit_type": "Uniform", "nsfw": false}` |
|
||||
| Action | `participants`, `nsfw` | `{"participants": "1girl, 1boy", "nsfw": true}` |
|
||||
| Style | `style_type`, `nsfw` | `{"style_type": "Anime", "nsfw": false}` |
|
||||
| Scene | `scene_type`, `nsfw` | `{"scene_type": "Indoor", "nsfw": false}` |
|
||||
| Detailer | `associated_resource`, `adetailer_targets`, `nsfw` | `{"associated_resource": "skin", "adetailer_targets": ["face", "hands"], "nsfw": false}` |
|
||||
| Checkpoint | `art_style`, `base_model`, `nsfw` | `{"art_style": "anime", "base_model": "Illustrious", "nsfw": false}` |
|
||||
|
||||
### Favourite / NSFW Columns
|
||||
|
||||
- `is_favourite` — DB-only boolean. Toggled via `POST /<category>/<slug>/favourite`. Not stored in JSON (user preference, not asset metadata).
|
||||
- `is_nsfw` — DB column **and** `tags.nsfw` in JSON. Synced from JSON on rescan via `_sync_nsfw_from_tags()`. Editable from edit pages.
|
||||
|
||||
### Library Filtering
|
||||
|
||||
All library index pages support query params:
|
||||
- `?favourite=on` — show only favourites
|
||||
- `?nsfw=sfw|nsfw|all` — filter by NSFW status
|
||||
- Results are ordered by `is_favourite DESC, name ASC` (favourites sort first).
|
||||
|
||||
### Gallery Image Sidecar Files
|
||||
|
||||
Gallery images can have per-image favourite/NSFW metadata stored in sidecar JSON files at `{image_path}.json` (e.g. `static/uploads/characters/tifa/gen_123.png.json`). Sidecar schema: `{"is_favourite": bool, "is_nsfw": bool}`.
|
||||
|
||||
---
|
||||
|
||||
## LoRA File Paths
|
||||
|
||||
LoRA filenames in JSON are stored as paths relative to ComfyUI's `models/lora/` root:
|
||||
@@ -526,5 +592,7 @@ Volumes mounted into the app container:
|
||||
- **LoRA chaining**: If a LoRA node has no LoRA (name is empty/None), the node is skipped and `model_source`/`clip_source` pass 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 `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. When merging into `tags` for `build_prompt`, use `extend` for lists and `append` for strings — never append the list object itself or `", ".join()` will fail on the nested list item.
|
||||
- **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. In generate routes, the detailer prompt is injected directly into `prompts['main']` after `build_prompt()` returns (not via tags or `build_prompt` itself).
|
||||
- **`_make_finalize` action semantics**: Pass `action=None` when the route should always update the DB cover (e.g. batch generate, checkpoint generate). Pass `action=request.form.get('action')` for routes that support both "preview" (no DB update) and "replace" (update DB). The factory skips the DB write when `action` is truthy and not `"replace"`.
|
||||
- **LLM queue runs without request context**: `_enqueue_task()` callbacks execute in a background thread with only `app.app_context()`. Do not access `flask.request`, `flask.session`, or other request-scoped objects inside `task_fn`. Use `has_request_context()` guard if code is shared between HTTP handlers and background tasks.
|
||||
- **Tags are metadata only**: Tags (`data['tags']`) are never injected into generation prompts. They are purely for UI filtering and search. The old pattern of `parts.extend(data.get('tags', []))` in prompt building has been removed.
|
||||
|
||||
Reference in New Issue
Block a user