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:
441
templates/partials/strengths_gallery.html
Normal file
441
templates/partials/strengths_gallery.html
Normal 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>⚡ 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>🎯 Active range:
|
||||
<strong id="sg-saved-min">{{ sg_weight_min }}</strong>
|
||||
–
|
||||
<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">🎲</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">🗑</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">
|
||||
💾 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 — 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">↓ 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 ↑</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 %}
|
||||
Reference in New Issue
Block a user