Files
character-browser/templates/gallery.html
Aodhan Collins ae7ba961c1 Add danbooru-mcp auto-start, git sync, status API endpoints, navbar status indicators, and LLM format retry
- app.py: add subprocess import; add _ensure_mcp_repo() to clone/pull
  danbooru-mcp from https://git.liveaodh.com/aodhan/danbooru-mcp into
  tools/danbooru-mcp/ at startup; add ensure_mcp_server_running() which
  calls _ensure_mcp_repo() then starts the Docker container if not running;
  add GET /api/status/comfyui and GET /api/status/mcp health endpoints;
  fix call_llm() to retry up to 3 times on unexpected response format
  (KeyError/IndexError), logging the raw response and prompting the LLM
  to respond with valid JSON before each retry
- templates/layout.html: add ComfyUI and MCP status dot indicators to
  navbar; add polling JS that checks both endpoints on load and every 30s
- static/style.css: add .service-status, .status-dot, .status-ok,
  .status-error, .status-checking styles and status-pulse keyframe animation
- .gitignore: add tools/ to exclude the cloned danbooru-mcp repo
2026-03-03 00:57:27 +00:00

433 lines
17 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "layout.html" %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Gallery
<span class="text-muted fs-6 fw-normal ms-2">{{ total }} image{{ 's' if total != 1 else '' }}</span>
</h4>
</div>
<!-- Filters -->
<form method="get" action="{{ url_for('gallery') }}" class="mb-3" id="filter-form">
<div class="row g-2 align-items-end">
<!-- Category -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Category</label>
<select name="category" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="all" {% if category == 'all' %}selected{% endif %}>All</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if category == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
{% endfor %}
</select>
</div>
<!-- Item (only when a category is selected) -->
{% if slug_options %}
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Item</label>
<select name="slug" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All items</option>
{% for s, n in slug_options %}
<option value="{{ s }}" {% if slug == s %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
{% else %}
<input type="hidden" name="slug" value="{{ slug }}">
{% endif %}
<!-- Sort -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Sort</label>
<select name="sort" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Newest first</option>
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Oldest first</option>
</select>
</div>
<!-- Per page -->
<div class="col-auto">
<label class="form-label form-label-sm mb-1">Per page</label>
<select name="per_page" class="form-select form-select-sm" onchange="this.form.submit()">
{% for n in [24, 48, 96] %}
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<!-- Active filter chips -->
<div class="col">
{% if category != 'all' %}
<span class="badge bg-primary me-1">
{{ category | capitalize }}
<a href="{{ url_for('gallery', sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
</span>
{% endif %}
{% if slug %}
<span class="badge bg-secondary me-1">
{{ slug }}
<a href="{{ url_for('gallery', category=category, sort=sort, per_page=per_page) }}" class="text-white ms-1 text-decoration-none">×</a>
</span>
{% endif %}
</div>
<input type="hidden" name="page" value="1">
</div>
</form>
<!-- Showing XY of N -->
{% if total > 0 %}
<p class="text-muted small mb-2">
Showing {{ (page - 1) * per_page + 1 }}{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}
</p>
{% endif %}
<!-- Grid -->
{% if images %}
<div class="gallery-grid mb-4">
{% set cat_colors = {
'characters': 'primary',
'actions': 'danger',
'outfits': 'success',
'scenes': 'info',
'styles': 'warning',
'detailers': 'secondary',
'checkpoints': 'dark',
} %}
{% for img in images %}
<div class="gallery-card"
data-category="{{ img.category }}"
data-slug="{{ img.slug }}"
data-name="{{ img.item_name }}"
data-path="{{ img.path }}"
data-src="{{ url_for('static', filename='uploads/' + img.path) }}"
onclick="openLightbox(this)">
<img src="{{ url_for('static', filename='uploads/' + img.path) }}"
alt="{{ img.item_name }}"
loading="lazy">
<span class="cat-badge badge bg-{{ cat_colors.get(img.category, 'secondary') }}">
{{ img.category[:-1] if img.category.endswith('s') else img.category }}
</span>
<div class="overlay">
<div class="text-white small fw-semibold text-truncate">{{ img.item_name }}</div>
<div class="d-flex gap-1 mt-1">
<button class="btn btn-sm btn-light py-0 px-2 flex-grow-1"
onclick="event.stopPropagation(); showPrompt({{ img.path | tojson }}, {{ img.item_name | tojson }}, {{ img.category | tojson }}, {{ img.slug | tojson }})">
Prompt
</button>
{% if img.category == 'characters' %}
<a href="{{ url_for('detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Open</a>
{% elif img.category == 'checkpoints' %}
<a href="{{ url_for('checkpoint_detail', slug=img.slug) }}"
class="btn btn-sm btn-outline-light py-0 px-2"
onclick="event.stopPropagation()">Open</a>
{% else %}
<a href="{{ url_for('generator') }}?{{ img.category[:-1] }}={{ img.slug }}"
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"
title="Delete"
onclick="event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})">
🗑
</button>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav>
<ul class="pagination pagination-sm flex-wrap justify-content-center">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=page-1) }}"></a>
</li>
{% set window = 3 %}
{% set ns = namespace(last=0) %}
{% for p in range(1, total_pages + 1) %}
{% if p == 1 or p == total_pages or (p >= page - window and p <= page + window) %}
{% if ns.last and p > ns.last + 1 %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=p) }}">{{ p }}</a>
</li>
{% set ns.last = p %}
{% endif %}
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('gallery', category=category, slug=slug, sort=sort, per_page=per_page, page=page+1) }}"></a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<div class="fs-1 mb-2">🖼️</div>
<p>No images found{% if category != 'all' %} in <strong>{{ category }}</strong>{% endif %}{% if slug %} for <strong>{{ slug }}</strong>{% endif %}.</p>
</div>
{% endif %}
<!-- Lightbox -->
<div id="lightbox" onclick="closeLightbox()">
<span id="lightbox-close" onclick="closeLightbox()">×</span>
<div id="lightbox-img-wrap" onclick="event.stopPropagation()">
<img id="lightbox-img" src="" alt="" onclick="openFullSize()">
<div id="lightbox-meta"></div>
<div id="lightbox-hint">Click image to open full size · Esc to close</div>
</div>
<div id="lightbox-actions" onclick="event.stopPropagation()">
<button class="btn btn-sm btn-light" onclick="lightboxShowPrompt()">View Prompt</button>
<button class="btn btn-sm btn-outline-danger" onclick="openDeleteModal(_lightboxPath, _lightboxName); closeLightbox()">Delete</button>
</div>
</div>
<!-- Prompt modal -->
<div class="modal fade" id="promptModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="promptModalTitle">Generation Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="promptLoading" class="text-center py-4">
<div class="spinner-border text-secondary"></div>
</div>
<div id="promptContent" class="d-none">
<!-- Prompts -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-semibold mb-0">Positive Prompt</label>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="copyField('promptPositive', this)">Copy</button>
</div>
<textarea id="promptPositive" class="form-control form-control-sm font-monospace" rows="5" readonly></textarea>
</div>
<div class="mb-3" id="negativeRow">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-semibold mb-0">Negative Prompt</label>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="copyField('promptNegative', this)">Copy</button>
</div>
<textarea id="promptNegative" class="form-control form-control-sm font-monospace" rows="2" readonly></textarea>
</div>
<!-- LoRAs -->
<div class="mb-3" id="lorasRow">
<label class="form-label fw-semibold mb-1">LoRAs</label>
<div id="lorasContainer"></div>
</div>
<!-- Generation params -->
<div class="mb-2" id="paramsRow">
<label class="form-label fw-semibold mb-1">Generation Parameters</label>
<div class="meta-grid" id="metaGrid"></div>
</div>
<div id="noMetaMsg" class="d-none">
<p class="text-muted small fst-italic mb-0">No generation metadata found in this image.</p>
</div>
</div>
</div>
<div class="modal-footer">
<a id="openGeneratorBtn" href="{{ url_for('generator') }}" class="btn btn-primary">Open in Generator</a>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Delete confirmation modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Image</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-0">Delete <strong id="deleteItemName"></strong>? The image file will be removed.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ---- Lightbox state ----
let _lightboxSrc = '';
let _lightboxPath = '';
let _lightboxCategory = '';
let _lightboxSlug = '';
let _lightboxName = '';
function openLightbox(card) {
_lightboxSrc = card.dataset.src;
_lightboxPath = card.dataset.path;
_lightboxCategory = card.dataset.category;
_lightboxSlug = card.dataset.slug;
_lightboxName = card.dataset.name;
document.getElementById('lightbox-img').src = _lightboxSrc;
document.getElementById('lightbox-meta').textContent = _lightboxName + ' · ' + _lightboxCategory;
document.getElementById('lightbox').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
document.body.style.overflow = '';
}
function openFullSize() {
window.open(_lightboxSrc, '_blank');
}
function lightboxShowPrompt() {
closeLightbox();
showPrompt(_lightboxPath, _lightboxName, _lightboxCategory, _lightboxSlug);
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
// ---- Prompt modal ----
let promptModal;
document.addEventListener('DOMContentLoaded', () => {
promptModal = new bootstrap.Modal(document.getElementById('promptModal'));
});
async function showPrompt(imgPath, name, category, slug) {
document.getElementById('promptModalTitle').textContent = name;
document.getElementById('promptContent').classList.add('d-none');
document.getElementById('promptLoading').classList.remove('d-none');
promptModal.show();
try {
const res = await fetch(`/gallery/prompt-data?path=${encodeURIComponent(imgPath)}`);
const data = await res.json();
// Positive prompt
document.getElementById('promptPositive').value = data.positive || '';
// Negative prompt
const neg = data.negative || '';
document.getElementById('promptNegative').value = neg;
document.getElementById('negativeRow').style.display = neg ? '' : 'none';
// LoRAs
const loras = (data.loras || []).filter(l => l.name);
const lorasContainer = document.getElementById('lorasContainer');
lorasContainer.innerHTML = '';
if (loras.length) {
loras.forEach(l => {
const chip = document.createElement('span');
chip.className = 'lora-chip';
// Show only the filename part of the path
const shortName = l.name.split('/').pop().replace('.safetensors', '');
chip.innerHTML = `${shortName} <span class="lora-strength">${Number(l.strength).toFixed(2)}</span>`;
chip.title = l.name;
lorasContainer.appendChild(chip);
});
}
document.getElementById('lorasRow').style.display = loras.length ? '' : 'none';
// Generation params
const params = [
['Checkpoint', data.checkpoint ? data.checkpoint.split('/').pop() : null],
['Seed', data.seed],
['Steps', data.steps],
['CFG', data.cfg],
['Sampler', data.sampler],
['Scheduler', data.scheduler],
];
const hasParams = params.some(([, v]) => v !== null && v !== undefined);
const grid = document.getElementById('metaGrid');
grid.innerHTML = '';
params.forEach(([label, val]) => {
if (val === null || val === undefined) return;
const lEl = document.createElement('span'); lEl.className = 'meta-label'; lEl.textContent = label;
const vEl = document.createElement('span'); vEl.className = 'meta-value'; vEl.textContent = val;
grid.appendChild(lEl); grid.appendChild(vEl);
});
document.getElementById('paramsRow').style.display = hasParams ? '' : 'none';
document.getElementById('noMetaMsg').classList.toggle('d-none',
!!(data.positive || loras.length || hasParams));
// Generator link
const genUrl = category === 'characters'
? `/character/${slug}`
: category === 'checkpoints'
? `/checkpoint/${slug}`
: `/generator?${category.replace(/s$/, '')}=${encodeURIComponent(slug)}`;
const genBtn = document.getElementById('openGeneratorBtn');
genBtn.href = genUrl;
genBtn.textContent = (category === 'characters' || category === 'checkpoints') ? 'Open' : 'Open in Generator';
} catch (e) {
document.getElementById('promptPositive').value = 'Error loading metadata.';
} finally {
document.getElementById('promptLoading').classList.add('d-none');
document.getElementById('promptContent').classList.remove('d-none');
}
}
function copyField(id, btn) {
const text = document.getElementById(id).value;
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = orig, 1500);
});
}
// ---- Delete modal ----
let _deletePath = '';
let deleteModal;
document.addEventListener('DOMContentLoaded', () => {
deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
});
function openDeleteModal(path, name) {
_deletePath = path;
document.getElementById('deleteItemName').textContent = name;
deleteModal.show();
}
async function confirmDelete() {
deleteModal.hide();
try {
const res = await fetch('/gallery/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: _deletePath}),
});
const data = await res.json();
if (data.status === 'ok') {
const card = document.querySelector(`.gallery-card[data-path="${CSS.escape(_deletePath)}"]`);
if (card) card.remove();
const countEl = document.querySelector('h4 .text-muted');
if (countEl) {
const m = countEl.textContent.match(/(\d+)/);
if (m) {
const n = parseInt(m[1]) - 1;
countEl.textContent = ` ${n} image${n !== 1 ? 's' : ''}`;
}
}
} else {
alert('Delete failed: ' + (data.error || 'unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e);
}
}
</script>
{% endblock %}