Files
character-browser/templates/index.html
Aodhan Collins 32a73b02f5 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>
2026-03-21 03:22:09 +00:00

99 lines
5.1 KiB
HTML

{% extends "layout.html" %}
{% block content %}
{% from "partials/library_toolbar.html" import library_toolbar %}
{{ library_toolbar(
title="Character",
category="characters",
create_url=url_for('create_character'),
create_label="Character",
has_batch_gen=true,
has_regen_all=true,
has_lora_create=false,
has_tags=true,
regen_tags_category="characters",
rescan_url=url_for('rescan'),
get_missing_url="/get_missing_characters",
clear_covers_url="/clear_all_covers",
generate_url_pattern="/character/{slug}/generate"
) }}
<!-- Filters -->
<form method="get" class="mb-3 d-flex gap-3 align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="favourite" value="on" id="favFilter" {% if favourite_filter == 'on' %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label small" for="favFilter">&#9733; Favourites</label>
</div>
<select name="nsfw" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="all" {% if nsfw_filter == 'all' %}selected{% endif %}>All ratings</option>
<option value="sfw" {% if nsfw_filter == 'sfw' %}selected{% endif %}>SFW only</option>
<option value="nsfw" {% if nsfw_filter == 'nsfw' %}selected{% endif %}>NSFW only</option>
</select>
</form>
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3">
{% for char in characters %}
<div class="col" id="card-{{ char.slug }}">
<div class="card h-100 character-card" onclick="window.location.href='/character/{{ char.slug }}'">
<div class="img-container">
{% if char.image_path %}
<img id="img-{{ char.slug }}" src="{{ url_for('static', filename='uploads/' + char.image_path) }}" alt="{{ char.name }}">
<span id="no-img-{{ char.slug }}" class="text-muted d-none">No Image</span>
{% else %}
{% set fallback = random_gen_image('characters', char.slug) %}
{% if fallback %}
<img id="img-{{ char.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ char.name }}" class="fallback-cover">
<span id="no-img-{{ char.slug }}" class="text-muted d-none">No Image</span>
{% else %}
<img id="img-{{ char.slug }}" src="" alt="{{ char.name }}" class="d-none">
<span id="no-img-{{ char.slug }}" class="text-muted">No Image</span>
{% endif %}
{% endif %}
</div>
<div class="card-body">
<h5 class="card-title text-center">
{% if char.is_favourite %}<span class="text-warning">&#9733;</span> {% endif %}{{ char.name }}
{% if char.is_nsfw %}<span class="badge bg-danger" style="font-size:0.6rem;vertical-align:middle;">NSFW</span>{% endif %}
</h5>
<p class="card-text small text-center text-muted">
{% set ns = namespace(parts=[]) %}
{% for section_key in ['identity', 'defaults'] %}
{% if char.data[section_key] is mapping %}
{% for v in char.data[section_key].values() %}
{% if v %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% set wardrobe = char.data.get('wardrobe', {}) %}
{% if wardrobe %}
{% set outfit_data = wardrobe.get('default', wardrobe) %}
{% if outfit_data is mapping %}
{% for v in outfit_data.values() %}
{% if v and v is string %}{% set ns.parts = ns.parts + [v] %}{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% if char.data.lora and char.data.lora.lora_triggers %}
{% set ns.parts = ns.parts + [char.data.lora.lora_triggers] %}
{% endif %}
{{ ns.parts | join(', ') }}
</p>
</div>
<div class="card-footer d-flex justify-content-between align-items-center p-1">
{% if char.data.lora and char.data.lora.lora_name %}
{% 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>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/library-toolbar.js') }}"></script>
{% endblock %}