- 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
442 lines
17 KiB
HTML
442 lines
17 KiB
HTML
{% 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 %}
|