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
This commit is contained in:
Aodhan Collins
2026-03-03 00:57:27 +00:00
parent 0b8802deb5
commit ae7ba961c1
1194 changed files with 17475 additions and 3268 deletions

View File

@@ -0,0 +1,441 @@
{% if sg_has_lora %}
{# -----------------------------------------------------------------------
Strengths Gallery partial
Required context variables (set via {% set %} before {% include %}):
sg_entity — the entity model object
sg_category — URL category string, e.g. 'outfits', 'characters'
sg_has_lora — boolean: entity has a non-empty lora_name
----------------------------------------------------------------------- #}
{% set sg_lora = sg_entity.data.lora if sg_entity.data.lora else {} %}
{% set sg_weight_min = sg_lora.get('lora_weight_min', sg_lora.get('lora_weight', 0.0)) %}
{% set sg_weight_max = sg_lora.get('lora_weight_max', sg_lora.get('lora_weight', 1.0)) %}
<div class="card mt-4" id="sg-card">
<div class="card-header d-flex justify-content-between align-items-center"
style="background: linear-gradient(135deg,#1a1a3e,#2a1a4e); cursor:pointer;"
onclick="document.getElementById('sg-body').classList.toggle('d-none')">
<span>&#9889; Strengths Gallery
<small class="text-muted ms-2" style="font-size:.8em;">
— sweep this LoRA weight with a fixed seed
</small>
</span>
<span id="sg-badge" class="badge bg-secondary">0 images</span>
</div>
<div class="card-body" id="sg-body">
{# Saved range indicator #}
<div id="sg-saved-range" class="alert alert-secondary py-1 px-2 mb-2 d-flex align-items-center gap-2" style="font-size:.85em;">
<span>&#127919; Active range:
<strong id="sg-saved-min">{{ sg_weight_min }}</strong>
&ndash;
<strong id="sg-saved-max">{{ sg_weight_max }}</strong>
</span>
<span class="text-muted ms-auto fst-italic" id="sg-save-status"></span>
</div>
{# Config row #}
<div class="row g-2 align-items-end mb-3">
<div class="col-sm-2">
<label class="form-label small mb-1">Min Weight</label>
<input type="number" id="sg-min" class="form-control form-control-sm"
value="{{ sg_weight_min }}" step="0.05" min="-5" max="5">
</div>
<div class="col-sm-2">
<label class="form-label small mb-1">Max Weight</label>
<input type="number" id="sg-max" class="form-control form-control-sm"
value="{{ sg_weight_max }}" step="0.05" min="-5" max="5">
</div>
<div class="col-sm-2">
<label class="form-label small mb-1">Interval</label>
<input type="number" id="sg-interval" class="form-control form-control-sm"
value="0.05" step="0.01" min="0.01" max="1.0">
</div>
<div class="col-sm-3">
<label class="form-label small mb-1">Seed</label>
<div class="input-group input-group-sm">
<input type="number" id="sg-seed" class="form-control" placeholder="auto">
<button class="btn btn-outline-secondary" type="button"
onclick="sgRollSeed()" title="Random seed">&#127922;</button>
</div>
</div>
<div class="col-sm-1">
<label class="form-label small mb-1">Count</label>
<span id="sg-step-count" class="form-control form-control-sm bg-transparent border-0 text-muted ps-0">11</span>
</div>
<div class="col-sm-2 d-flex flex-column gap-1">
<div class="d-flex gap-1">
<button id="sg-btn-run" class="btn btn-sm btn-primary flex-grow-1"
onclick="sgStart()">Generate</button>
<button id="sg-btn-stop" class="btn btn-sm btn-warning d-none"
onclick="sgStop()">Stop</button>
<button id="sg-btn-clear" class="btn btn-sm btn-outline-danger"
onclick="sgClear()" title="Clear results">&#128465;</button>
</div>
<button id="sg-btn-save-range" class="btn btn-sm btn-outline-success w-100"
onclick="sgSaveRange()" title="Save current Min/Max as the randomisation range for this LoRA">
&#128190; Save Range
</button>
</div>
</div>
{# Progress #}
<div id="sg-progress" class="d-none mb-3">
<div class="progress" style="height:6px;">
<div id="sg-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated"
style="width:0%"></div>
</div>
<small class="text-muted" id="sg-progress-label">0 / 0 &mdash; weight: —</small>
</div>
{# Results grid #}
<div id="sg-grid" class="row g-2">
{# Populated by JS on load and after each generation step #}
</div>
</div>{# /card-body #}
</div>{# /card #}
<script>
(function () {
'use strict';
const SG_CAT = {{ sg_category | tojson }};
const SG_SLUG = {{ sg_entity.slug | tojson }};
const SG_WS = {{ COMFYUI_WS_URL | tojson }};
const SG_CLIENT_ID = 'sg_' + Math.random().toString(36).slice(2, 10);
let sgRunning = false;
let sgShouldStop = false;
// ---- helpers ----
function sgRollSeed() {
document.getElementById('sg-seed').value = Math.floor(Math.random() * 1e15);
}
function sgGetInterval() {
const v = parseFloat(document.getElementById('sg-interval').value);
if (isNaN(v) || v < 0.01) return 0.01;
if (v > 1.0) return 1.0;
return v;
}
function sgBuildSteps(min, max, interval) {
if (min > max) return [];
interval = interval || 0.1;
const scale = Math.round(1 / interval);
const steps = [];
let v = min;
while (v <= max + interval * 0.5) {
steps.push(Math.round(v * scale) / scale);
v = Math.round((v + interval) * scale) / scale;
}
return steps;
}
// ---- validation & visual feedback ----
function sgValidateBounds() {
const minEl = document.getElementById('sg-min');
const maxEl = document.getElementById('sg-max');
const min = parseFloat(minEl.value);
const max = parseFloat(maxEl.value);
const invalid = !isNaN(min) && !isNaN(max) && min > max;
minEl.classList.toggle('is-invalid', invalid);
maxEl.classList.toggle('is-invalid', invalid);
return !invalid;
}
function sgUpdateCount() {
const valid = sgValidateBounds();
const min = parseFloat(document.getElementById('sg-min').value) || 0;
const max = parseFloat(document.getElementById('sg-max').value) || 1;
const count = valid ? sgBuildSteps(min, max, sgGetInterval()).length : 0;
document.getElementById('sg-step-count').textContent = count;
}
// Clamp on blur: push the other bound to match rather than block the user mid-type
document.getElementById('sg-min').addEventListener('change', () => {
const minEl = document.getElementById('sg-min');
const maxEl = document.getElementById('sg-max');
const min = parseFloat(minEl.value), max = parseFloat(maxEl.value);
if (!isNaN(min) && !isNaN(max) && min > max) maxEl.value = min;
sgUpdateCount();
sgHighlightBounds();
});
document.getElementById('sg-max').addEventListener('change', () => {
const minEl = document.getElementById('sg-min');
const maxEl = document.getElementById('sg-max');
const min = parseFloat(minEl.value), max = parseFloat(maxEl.value);
if (!isNaN(min) && !isNaN(max) && max < min) minEl.value = max;
sgUpdateCount();
sgHighlightBounds();
});
document.getElementById('sg-min').addEventListener('input', () => { sgUpdateCount(); sgHighlightBounds(); });
document.getElementById('sg-max').addEventListener('input', () => { sgUpdateCount(); sgHighlightBounds(); });
document.getElementById('sg-interval').addEventListener('input', sgUpdateCount);
// ---- highlight matching min/max buttons ----
function sgHighlightBounds() {
const currentMin = parseFloat(document.getElementById('sg-min').value);
const currentMax = parseFloat(document.getElementById('sg-max').value);
document.querySelectorAll('#sg-grid .sg-thumb').forEach(thumb => {
const sv = parseFloat(thumb.dataset.sgStrength);
const minBtn = thumb.querySelector('[data-sg-role="min-btn"]');
const maxBtn = thumb.querySelector('[data-sg-role="max-btn"]');
if (minBtn) {
const active = sv === currentMin;
minBtn.classList.toggle('btn-primary', active);
minBtn.classList.toggle('btn-outline-primary', !active);
}
if (maxBtn) {
const active = sv === currentMax;
maxBtn.classList.toggle('btn-warning', active);
maxBtn.classList.toggle('btn-outline-warning', !active);
}
});
}
function sgUpdateBadge() {
const count = document.querySelectorAll('#sg-grid .sg-thumb').length;
document.getElementById('sg-badge').textContent = count + ' image' + (count !== 1 ? 's' : '');
}
function sgSetMin(v) {
const maxEl = document.getElementById('sg-max');
document.getElementById('sg-min').value = v;
if (v > parseFloat(maxEl.value)) maxEl.value = v;
sgUpdateCount();
sgHighlightBounds();
}
function sgSetMax(v) {
const minEl = document.getElementById('sg-min');
document.getElementById('sg-max').value = v;
if (v < parseFloat(minEl.value)) minEl.value = v;
sgUpdateCount();
sgHighlightBounds();
}
function sgAddImage(imageUrl, strengthValue) {
const grid = document.getElementById('sg-grid');
const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
col.innerHTML = `
<div class="card h-100 sg-thumb" data-sg-strength="${strengthValue}">
<img src="${imageUrl}" class="card-img-top" style="object-fit:cover;height:160px;cursor:zoom-in;"
loading="lazy" onclick="window.open(this.src,'_blank')">
<div class="card-footer py-1 px-1">
<div class="text-center mb-1"><span class="badge bg-secondary">${strengthValue}</span></div>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm flex-grow-1 py-0" data-sg-role="min-btn"
style="font-size:.7em;" onclick="sgSetMin(${strengthValue})" title="Set as Min weight">&#8595; Min</button>
<button class="btn btn-outline-warning btn-sm flex-grow-1 py-0" data-sg-role="max-btn"
style="font-size:.7em;" onclick="sgSetMax(${strengthValue})" title="Set as Max weight">Max &#8593;</button>
</div>
</div>
</div>`;
grid.appendChild(col);
sgUpdateBadge();
sgHighlightBounds();
}
// ---- WebSocket wait ----
function sgWaitForCompletion(promptId) {
return new Promise((resolve, reject) => {
let ws;
try {
ws = new WebSocket(`${SG_WS}?clientId=${SG_CLIENT_ID}`);
} catch (e) {
// Fall back to polling if WS unavailable
sgPollUntilDone(promptId).then(resolve).catch(reject);
return;
}
const timeout = setTimeout(() => {
ws.close();
sgPollUntilDone(promptId).then(resolve).catch(reject);
}, 120000);
ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
if (msg.type === 'executing' && msg.data && msg.data.prompt_id === promptId) {
if (msg.data.node === null) {
clearTimeout(timeout);
ws.close();
resolve();
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
sgPollUntilDone(promptId).then(resolve).catch(reject);
};
});
}
async function sgPollUntilDone(promptId) {
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 2000));
const r = await fetch(`/check_status/${promptId}`);
const d = await r.json();
if (d.status === 'complete' || d.status === 'finished' || d.done) return;
}
}
// ---- main flow ----
async function sgClearImages() {
await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/clear`, { method: 'POST' });
document.getElementById('sg-grid').innerHTML = '';
sgUpdateBadge();
}
async function sgStart() {
if (sgRunning) return;
const min = parseFloat(document.getElementById('sg-min').value);
const max = parseFloat(document.getElementById('sg-max').value);
let seed = parseInt(document.getElementById('sg-seed').value);
if (isNaN(seed)) {
seed = Math.floor(Math.random() * 1e15);
document.getElementById('sg-seed').value = seed;
}
if (!sgValidateBounds()) return;
const steps = sgBuildSteps(min, max, sgGetInterval());
if (!steps.length) return;
// Clear any previous set before starting a new one
await sgClearImages();
sgRunning = true;
sgShouldStop = false;
document.getElementById('sg-btn-run').classList.add('d-none');
document.getElementById('sg-btn-stop').classList.remove('d-none');
document.getElementById('sg-progress').classList.remove('d-none');
for (let i = 0; i < steps.length; i++) {
if (sgShouldStop) break;
const sv = steps[i];
const pct = Math.round(((i) / steps.length) * 100);
document.getElementById('sg-progress-bar').style.width = pct + '%';
document.getElementById('sg-progress-label').textContent =
`${i} / ${steps.length} \u2014 weight: ${sv}`;
try {
// Queue one generation
// Pick up the character currently selected on this detail page (if any)
const charSelect = document.getElementById('character_select');
const charSlug = charSelect ? charSelect.value : '';
const formData = new URLSearchParams({
strength_value: sv,
seed: seed,
client_id: SG_CLIENT_ID,
character_slug: charSlug,
});
const queueResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/generate`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData,
});
const queueData = await queueResp.json();
if (!queueData.prompt_id) throw new Error('No prompt_id returned');
await sgWaitForCompletion(queueData.prompt_id);
// Finalize
const finData = new URLSearchParams({ strength_value: sv, seed: seed });
const finResp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/finalize/${queueData.prompt_id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: finData,
});
const finJson = await finResp.json();
if (finJson.success && finJson.image_url) {
sgAddImage(finJson.image_url, sv);
}
} catch (err) {
console.error('[Strengths] step error:', sv, err);
}
}
document.getElementById('sg-progress-bar').style.width = '100%';
document.getElementById('sg-progress-label').textContent =
`Done \u2014 ${steps.length} images generated`;
setTimeout(() => document.getElementById('sg-progress').classList.add('d-none'), 3000);
document.getElementById('sg-btn-stop').classList.add('d-none');
document.getElementById('sg-btn-run').classList.remove('d-none');
sgRunning = false;
}
function sgStop() {
sgShouldStop = true;
document.getElementById('sg-btn-stop').classList.add('d-none');
document.getElementById('sg-btn-run').classList.remove('d-none');
}
async function sgClear() {
if (!confirm('Clear all Strengths Gallery images for this item?')) return;
await sgClearImages();
}
async function sgSaveRange() {
const min = parseFloat(document.getElementById('sg-min').value);
const max = parseFloat(document.getElementById('sg-max').value);
if (isNaN(min) || isNaN(max)) {
alert('Set valid Min and Max values first.');
return;
}
const statusEl = document.getElementById('sg-save-status');
statusEl.textContent = 'Saving…';
try {
const body = new URLSearchParams({ min_weight: Math.min(min, max), max_weight: Math.max(min, max) });
const resp = await fetch(`/strengths/${SG_CAT}/${SG_SLUG}/save_range`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const data = await resp.json();
if (data.success) {
document.getElementById('sg-saved-min').textContent = data.lora_weight_min;
document.getElementById('sg-saved-max').textContent = data.lora_weight_max;
statusEl.textContent = '✓ Saved';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else {
statusEl.textContent = '✗ ' + (data.error || 'Error');
}
} catch (e) {
statusEl.textContent = '✗ Network error';
}
}
// Expose functions to inline onclick handlers
window.sgStart = sgStart;
window.sgStop = sgStop;
window.sgClear = sgClear;
window.sgSaveRange = sgSaveRange;
window.sgRollSeed = sgRollSeed;
window.sgSetMin = sgSetMin;
window.sgSetMax = sgSetMax;
// ---- Load existing images on page load ----
fetch(`/strengths/${SG_CAT}/${SG_SLUG}/list`)
.then(r => r.json())
.then(data => {
(data.images || []).forEach(img => sgAddImage(img.url, img.strength));
sgUpdateCount();
sgHighlightBounds();
})
.catch(() => sgUpdateCount());
})();
</script>
{% endif %}