- Add extra positive/negative prompt textareas to all 9 detail pages with session persistence - Add Endless generation button to all detail pages (continuous preview generation until stopped) - Default character selector to "Random Character" on all secondary detail pages - Fix queue clear endpoint (remove spurious auth check) - Refactor app.py into routes/ and services/ modules - Update CLAUDE.md with new architecture documentation - Various data file updates and cleanup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
961 lines
42 KiB
HTML
961 lines
42 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>GAZE</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar navbar-dark mb-4">
|
|
<div class="container">
|
|
<a class="navbar-brand d-flex align-items-center gap-2" href="/"><img src="{{ url_for('static', filename='icons/gaze-logo.png') }}" class="navbar-logo">GAZE</a>
|
|
<div class="d-flex align-items-center gap-1 flex-wrap">
|
|
<a href="/" class="btn btn-sm btn-outline-light">Characters</a>
|
|
<a href="/outfits" class="btn btn-sm btn-outline-light">Outfits</a>
|
|
<a href="/looks" class="btn btn-sm btn-outline-light">Looks</a>
|
|
<a href="/actions" class="btn btn-sm btn-outline-light">Actions</a>
|
|
<a href="/styles" class="btn btn-sm btn-outline-light">Styles</a>
|
|
<a href="/scenes" class="btn btn-sm btn-outline-light">Scenes</a>
|
|
<a href="/detailers" class="btn btn-sm btn-outline-light">Detailers</a>
|
|
<a href="/checkpoints" class="btn btn-sm btn-outline-light">Checkpoints</a>
|
|
<a href="/presets" class="btn btn-sm btn-outline-light">Presets</a>
|
|
<div class="vr mx-1 d-none d-lg-block"></div>
|
|
<a href="/create" class="btn btn-sm btn-outline-success">+ Character</a>
|
|
<a href="/generator" class="btn btn-sm btn-outline-light">Generator</a>
|
|
<a href="/gallery" class="btn btn-sm btn-outline-light">Image Gallery</a>
|
|
<a href="/settings" class="btn btn-sm btn-outline-light">Settings</a>
|
|
<div class="vr mx-1 d-none d-lg-block"></div>
|
|
<!-- Queue indicator -->
|
|
<button id="queue-btn" class="btn btn-sm queue-btn" data-bs-toggle="modal" data-bs-target="#queueModal" title="Generation Queue">
|
|
<span class="queue-icon">⏳</span>
|
|
<span id="queue-count-badge" class="queue-badge d-none">0</span>
|
|
</button>
|
|
<div class="vr mx-1 d-none d-lg-block"></div>
|
|
<!-- Service status indicators -->
|
|
<span id="status-comfyui" class="service-status" title="ComfyUI" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="ComfyUI: checking…">
|
|
<span class="status-dot status-checking"></span>
|
|
<span class="status-label d-none d-xl-inline">ComfyUI</span>
|
|
</span>
|
|
<span id="status-mcp" class="service-status" title="MCP" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="MCP: checking…">
|
|
<span class="status-dot status-checking"></span>
|
|
<span class="status-label d-none d-xl-inline">MCP</span>
|
|
</span>
|
|
<span id="status-llm" class="service-status" title="LLM" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="LLM: checking…">
|
|
<span class="status-dot status-checking"></span>
|
|
<span class="status-label d-none d-xl-inline">LLM</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
{% with messages = get_flashed_messages() %}
|
|
{% if messages %}
|
|
{% for message in messages %}
|
|
<div class="alert alert-info">{{ message }}</div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
{% endwith %}
|
|
|
|
{% block content %}{% endblock %}
|
|
</div>
|
|
|
|
<!-- Resource delete modal (shared across category gallery pages) -->
|
|
<div class="modal fade" id="resourceDeleteModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Delete <span id="rdm-name"></span></h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="d-grid gap-2">
|
|
<button class="btn btn-outline-secondary text-start" onclick="confirmResourceDelete('soft')">
|
|
<strong>Soft delete</strong> — remove entry from this gallery<br>
|
|
<small class="text-muted">JSON data file deleted; LoRA/checkpoint file kept on disk</small>
|
|
</button>
|
|
<button class="btn btn-outline-danger text-start" onclick="confirmResourceDelete('hard')">
|
|
<strong>Hard delete</strong> — remove entry and all associated files<br>
|
|
<small class="text-muted">Deletes JSON data file and LoRA/checkpoint safetensors from disk. Irreversible.</small>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Generation Queue Modal -->
|
|
<div class="modal fade" id="queueModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
Generation Queue
|
|
<span id="queue-modal-count" class="badge bg-secondary ms-2">0</span>
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-0">
|
|
<div id="queue-empty-msg" class="text-center text-muted py-4">
|
|
<p class="mb-0">No jobs in queue.</p>
|
|
</div>
|
|
<ul id="queue-job-list" class="list-group list-group-flush d-none"></ul>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<small class="text-muted me-auto">Jobs are processed sequentially. Close this window to continue browsing.</small>
|
|
<button type="button" id="queue-clear-btn" class="btn btn-warning btn-sm" onclick="queueClearAll()">
|
|
🗑️ Clear Queue
|
|
</button>
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Gallery Navigation Modal -->
|
|
<div class="modal fade" id="galleryModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
|
<div class="modal-content bg-transparent border-0">
|
|
<div class="modal-body p-0 text-center position-relative">
|
|
<!-- Navigation Arrows -->
|
|
<button type="button" id="gallery-prev" aria-label="Previous image" class="btn btn-dark btn-lg position-absolute start-0 top-50 translate-middle-y ms-2 opacity-75 hover-opacity-100"
|
|
style="z-index: 1050; border-radius: 50%; width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
|
|
‹
|
|
</button>
|
|
<button type="button" id="gallery-next" aria-label="Next image" class="btn btn-dark btn-lg position-absolute end-0 top-50 translate-middle-y me-2 opacity-75 hover-opacity-100"
|
|
style="z-index: 1050; border-radius: 50%; width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
|
|
›
|
|
</button>
|
|
<!-- Image Counter -->
|
|
<div id="gallery-counter" class="position-absolute bottom-0 start-50 translate-middle-x mb-3 px-3 py-1 bg-dark rounded text-white opacity-75"
|
|
style="z-index: 1050; font-size: 0.9rem;">
|
|
1 / 1
|
|
</div>
|
|
<!-- Main Image -->
|
|
<img id="galleryImage" src="" alt="Gallery Image" class="img-fluid" style="max-height: 90vh; cursor: default;">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
|
|
});
|
|
|
|
// ---- Loaded checkpoint → ComfyUI tooltip ----
|
|
(function() {
|
|
let _loadedCheckpoint = null;
|
|
async function pollLoadedCheckpoint() {
|
|
try {
|
|
const r = await fetch('/api/comfyui/loaded_checkpoint', { cache: 'no-store' });
|
|
const data = await r.json();
|
|
_loadedCheckpoint = data.checkpoint || null;
|
|
} catch {
|
|
_loadedCheckpoint = null;
|
|
}
|
|
updateComfyTooltip();
|
|
}
|
|
function updateComfyTooltip() {
|
|
const el = document.getElementById('status-comfyui');
|
|
if (!el) return;
|
|
const dot = el.querySelector('.status-dot');
|
|
const online = dot && dot.classList.contains('status-ok');
|
|
let text = 'ComfyUI: ' + (online ? 'online' : 'offline');
|
|
if (_loadedCheckpoint) {
|
|
const parts = _loadedCheckpoint.split(/[/\\]/);
|
|
const name = parts[parts.length - 1].replace(/\.safetensors$/, '');
|
|
text += '\n' + name;
|
|
}
|
|
el.setAttribute('data-bs-title', text);
|
|
el.setAttribute('title', text);
|
|
const tip = bootstrap.Tooltip.getInstance(el);
|
|
if (tip) tip.setContent({ '.tooltip-inner': text });
|
|
}
|
|
// Hook into the existing status polling to refresh tooltip after status changes
|
|
window._updateComfyTooltip = updateComfyTooltip;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
pollLoadedCheckpoint();
|
|
setInterval(pollLoadedCheckpoint, 30000);
|
|
});
|
|
})();
|
|
</script>
|
|
<script>
|
|
// ---- Resource delete modal (category galleries) ----
|
|
let _rdmCategory = '', _rdmSlug = '';
|
|
let resourceDeleteModal;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const el = document.getElementById('resourceDeleteModal');
|
|
if (el) resourceDeleteModal = new bootstrap.Modal(el);
|
|
|
|
// Attach listeners to resource delete buttons via data attrs (avoids inline onclick issues)
|
|
document.querySelectorAll('.resource-delete-btn').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
openResourceDeleteModal(this.dataset.category, this.dataset.slug, this.dataset.name);
|
|
});
|
|
});
|
|
});
|
|
function openResourceDeleteModal(category, slug, name) {
|
|
_rdmCategory = category;
|
|
_rdmSlug = slug;
|
|
document.getElementById('rdm-name').textContent = name;
|
|
resourceDeleteModal.show();
|
|
}
|
|
async function confirmResourceDelete(mode) {
|
|
resourceDeleteModal.hide();
|
|
try {
|
|
const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({mode}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'ok') {
|
|
const card = document.getElementById(`card-${_rdmSlug}`);
|
|
if (card) card.remove();
|
|
} else {
|
|
alert('Delete failed: ' + (data.error || 'unknown error'));
|
|
}
|
|
} catch (e) {
|
|
alert('Delete failed: ' + e);
|
|
}
|
|
}
|
|
|
|
function initJsonEditor(saveUrl) {
|
|
const jsonModal = document.getElementById('jsonEditorModal');
|
|
if (!jsonModal) return;
|
|
const textarea = document.getElementById('json-editor-textarea');
|
|
const errBox = document.getElementById('json-editor-error');
|
|
const simplePanel = document.getElementById('json-simple-panel');
|
|
const advancedPanel = document.getElementById('json-advanced-panel');
|
|
const simpleTab = document.getElementById('json-simple-tab');
|
|
const advancedTab = document.getElementById('json-advanced-tab');
|
|
let activeTab = 'simple';
|
|
|
|
function buildSimpleForm(data) {
|
|
simplePanel.innerHTML = '';
|
|
for (const [key, value] of Object.entries(data)) {
|
|
const row = document.createElement('div');
|
|
row.className = 'row mb-2 align-items-start';
|
|
|
|
const labelCol = document.createElement('div');
|
|
labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1';
|
|
labelCol.textContent = key.replace(/_/g, ' ');
|
|
|
|
const inputCol = document.createElement('div');
|
|
inputCol.className = 'col-sm-9';
|
|
|
|
let el;
|
|
if (typeof value === 'boolean') {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'form-check mt-2';
|
|
el = document.createElement('input');
|
|
el.type = 'checkbox';
|
|
el.className = 'form-check-input';
|
|
el.checked = value;
|
|
el.dataset.dtype = 'boolean';
|
|
wrap.appendChild(el);
|
|
inputCol.appendChild(wrap);
|
|
} else if (typeof value === 'number') {
|
|
el = document.createElement('input');
|
|
el.type = 'number';
|
|
el.step = 'any';
|
|
el.className = 'form-control form-control-sm';
|
|
el.value = value;
|
|
el.dataset.dtype = 'number';
|
|
inputCol.appendChild(el);
|
|
} else if (typeof value === 'string') {
|
|
if (value.length > 80) {
|
|
el = document.createElement('textarea');
|
|
el.className = 'form-control form-control-sm';
|
|
el.rows = 3;
|
|
} else {
|
|
el = document.createElement('input');
|
|
el.type = 'text';
|
|
el.className = 'form-control form-control-sm';
|
|
}
|
|
el.value = value;
|
|
el.dataset.dtype = 'string';
|
|
inputCol.appendChild(el);
|
|
} else {
|
|
el = document.createElement('textarea');
|
|
el.className = 'form-control form-control-sm font-monospace';
|
|
const lines = JSON.stringify(value, null, 2).split('\n');
|
|
el.rows = Math.min(10, lines.length + 1);
|
|
el.value = JSON.stringify(value, null, 2);
|
|
el.dataset.dtype = 'json';
|
|
inputCol.appendChild(el);
|
|
}
|
|
el.dataset.key = key;
|
|
row.appendChild(labelCol);
|
|
row.appendChild(inputCol);
|
|
simplePanel.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function readSimpleForm() {
|
|
const result = {};
|
|
simplePanel.querySelectorAll('[data-key]').forEach(el => {
|
|
const key = el.dataset.key;
|
|
const dtype = el.dataset.dtype;
|
|
if (dtype === 'boolean') result[key] = el.checked;
|
|
else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; }
|
|
else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } }
|
|
else result[key] = el.value;
|
|
});
|
|
return result;
|
|
}
|
|
|
|
simpleTab.addEventListener('click', () => {
|
|
errBox.classList.add('d-none');
|
|
let data;
|
|
try { data = JSON.parse(textarea.value); }
|
|
catch (e) { errBox.textContent = 'Cannot switch: invalid JSON — ' + e.message; errBox.classList.remove('d-none'); return; }
|
|
buildSimpleForm(data);
|
|
simplePanel.classList.remove('d-none');
|
|
advancedPanel.classList.add('d-none');
|
|
simpleTab.classList.add('active');
|
|
advancedTab.classList.remove('active');
|
|
activeTab = 'simple';
|
|
});
|
|
|
|
advancedTab.addEventListener('click', () => {
|
|
textarea.value = JSON.stringify(readSimpleForm(), null, 2);
|
|
advancedPanel.classList.remove('d-none');
|
|
simplePanel.classList.add('d-none');
|
|
advancedTab.classList.add('active');
|
|
simpleTab.classList.remove('active');
|
|
activeTab = 'advanced';
|
|
});
|
|
|
|
jsonModal.addEventListener('show.bs.modal', () => {
|
|
const raw = document.getElementById('json-raw-data').textContent;
|
|
let data;
|
|
try { data = JSON.parse(raw); } catch { data = {}; }
|
|
buildSimpleForm(data);
|
|
textarea.value = JSON.stringify(data, null, 2);
|
|
simplePanel.classList.remove('d-none');
|
|
advancedPanel.classList.add('d-none');
|
|
simpleTab.classList.add('active');
|
|
advancedTab.classList.remove('active');
|
|
activeTab = 'simple';
|
|
errBox.classList.add('d-none');
|
|
});
|
|
|
|
document.getElementById('json-save-btn').addEventListener('click', async () => {
|
|
errBox.classList.add('d-none');
|
|
let parsed;
|
|
if (activeTab === 'simple') {
|
|
parsed = readSimpleForm();
|
|
} else {
|
|
try { parsed = JSON.parse(textarea.value); }
|
|
catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; }
|
|
}
|
|
const fd = new FormData();
|
|
fd.append('json_data', JSON.stringify(parsed));
|
|
const resp = await fetch(saveUrl, { method: 'POST', body: fd });
|
|
const result = await resp.json();
|
|
if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); }
|
|
else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); }
|
|
});
|
|
}
|
|
</script>
|
|
<script>
|
|
// ---- Service status indicators ----
|
|
(function () {
|
|
const services = [
|
|
{ id: 'status-comfyui', url: '/api/status/comfyui', label: 'ComfyUI' },
|
|
{ id: 'status-llm', url: '/api/status/llm', label: 'LLM' },
|
|
];
|
|
|
|
// MCP services (checked separately for combined indicator)
|
|
const mcpServices = [
|
|
{ url: '/api/status/mcp', label: 'Danbooru MCP', key: 'danbooru' },
|
|
{ url: '/api/status/character-mcp', label: 'Character MCP', key: 'character' },
|
|
];
|
|
|
|
// Global service status tracker
|
|
window.serviceStatus = {
|
|
comfyui: false,
|
|
mcp: false,
|
|
characterMcp: false,
|
|
llm: false,
|
|
mcpStatuses: {
|
|
danbooru: false,
|
|
character: false
|
|
}
|
|
};
|
|
|
|
function updateButtons() {
|
|
// Image generation buttons require ComfyUI
|
|
const imageGenButtons = document.querySelectorAll('.btn-image-gen, [data-requires="comfyui"], #generate-btn, button[form="generate-form"], #batch-generate-btn, #regenerate-all-btn, #generate-all-btn');
|
|
imageGenButtons.forEach(btn => {
|
|
if (!window.serviceStatus.comfyui) {
|
|
btn.disabled = true;
|
|
btn.title = btn.title || 'ComfyUI is not available';
|
|
} else {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// JSON generation buttons require Danbooru MCP or LLM
|
|
const jsonGenButtons = document.querySelectorAll('.btn-json-gen, [data-requires="mcp-llm"], button[data-bs-target="#jsonEditorModal"], #json-save-btn');
|
|
jsonGenButtons.forEach(btn => {
|
|
if (!window.serviceStatus.mcp && !window.serviceStatus.llm) {
|
|
btn.disabled = true;
|
|
btn.title = btn.title || 'Danbooru MCP or LLM is required';
|
|
} else {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// Character generation buttons require Character MCP or LLM
|
|
const charGenButtons = document.querySelectorAll('.btn-char-gen, [data-requires="char-mcp-llm"], #submit-btn, button[form*="create"]');
|
|
charGenButtons.forEach(btn => {
|
|
if (!window.serviceStatus.characterMcp && !window.serviceStatus.llm) {
|
|
btn.disabled = true;
|
|
btn.title = btn.title || 'Character MCP or LLM is required';
|
|
} else {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateMcpStatus() {
|
|
const el = document.getElementById('status-mcp');
|
|
if (!el) return;
|
|
|
|
const statuses = window.serviceStatus.mcpStatuses;
|
|
const allOnline = statuses.danbooru && statuses.character;
|
|
const allOffline = !statuses.danbooru && !statuses.character;
|
|
const someOffline = !allOnline && !allOffline;
|
|
|
|
// Update global status flags
|
|
window.serviceStatus.mcp = statuses.danbooru;
|
|
window.serviceStatus.characterMcp = statuses.character;
|
|
|
|
const dot = el.querySelector('.status-dot');
|
|
|
|
// Set status class: red (all offline), yellow (some offline), green (all online)
|
|
if (allOffline) {
|
|
dot.className = 'status-dot status-error';
|
|
} else if (someOffline) {
|
|
dot.className = 'status-dot status-warning';
|
|
} else {
|
|
dot.className = 'status-dot status-ok';
|
|
}
|
|
|
|
// Build detailed tooltip
|
|
const tooltipLines = [
|
|
'MCP Services:',
|
|
`Danbooru MCP: ${statuses.danbooru ? 'online' : 'offline'}`,
|
|
`Character MCP: ${statuses.character ? 'online' : 'offline'}`
|
|
];
|
|
const tooltipText = tooltipLines.join('\n');
|
|
|
|
el.setAttribute('data-bs-title', tooltipText);
|
|
el.setAttribute('title', tooltipText);
|
|
const tip = bootstrap.Tooltip.getInstance(el);
|
|
if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
|
|
|
|
// Update buttons based on new status
|
|
updateButtons();
|
|
}
|
|
|
|
function setStatus(id, label, ok) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
const dot = el.querySelector('.status-dot');
|
|
dot.className = 'status-dot ' + (ok ? 'status-ok' : 'status-error');
|
|
if (id === 'status-comfyui' && window._updateComfyTooltip) {
|
|
window._updateComfyTooltip();
|
|
}
|
|
const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
|
|
el.setAttribute('data-bs-title', tooltipText);
|
|
el.setAttribute('title', tooltipText);
|
|
const tip = bootstrap.Tooltip.getInstance(el);
|
|
if (tip) tip.setContent({ '.tooltip-inner': tooltipText });
|
|
|
|
// Update global status
|
|
if (id === 'status-comfyui') window.serviceStatus.comfyui = ok;
|
|
if (id === 'status-llm') window.serviceStatus.llm = ok;
|
|
|
|
// Update buttons based on new status
|
|
updateButtons();
|
|
}
|
|
|
|
async function pollService(svc) {
|
|
try {
|
|
const r = await fetch(svc.url, { cache: 'no-store' });
|
|
const data = await r.json();
|
|
setStatus(svc.id, svc.label, data.status === 'ok');
|
|
} catch {
|
|
setStatus(svc.id, svc.label, false);
|
|
}
|
|
}
|
|
|
|
async function pollMcpService(svc) {
|
|
try {
|
|
const r = await fetch(svc.url, { cache: 'no-store' });
|
|
const data = await r.json();
|
|
window.serviceStatus.mcpStatuses[svc.key] = data.status === 'ok';
|
|
} catch {
|
|
window.serviceStatus.mcpStatuses[svc.key] = false;
|
|
}
|
|
}
|
|
|
|
async function pollAll() {
|
|
// Poll regular services
|
|
await Promise.all(services.map(pollService));
|
|
|
|
// Poll MCP services
|
|
await Promise.all(mcpServices.map(pollMcpService));
|
|
|
|
// Update combined MCP status
|
|
updateMcpStatus();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
pollAll();
|
|
setInterval(pollAll, 30000); // re-check every 30 s
|
|
});
|
|
})();
|
|
</script>
|
|
<script>
|
|
// ---- Generation Queue UI ----
|
|
(function() {
|
|
const badge = document.getElementById('queue-count-badge');
|
|
const modalCount = document.getElementById('queue-modal-count');
|
|
const jobList = document.getElementById('queue-job-list');
|
|
const emptyMsg = document.getElementById('queue-empty-msg');
|
|
|
|
const STATUS_LABELS = {
|
|
pending: { text: 'Pending', cls: 'text-muted' },
|
|
processing: { text: 'Generating…', cls: 'text-warning' },
|
|
paused: { text: 'Paused', cls: 'text-secondary' },
|
|
done: { text: 'Done', cls: 'text-success' },
|
|
failed: { text: 'Failed', cls: 'text-danger' },
|
|
removed: { text: 'Removed', cls: 'text-muted' },
|
|
};
|
|
|
|
function renderQueue(jobs) {
|
|
const activeJobs = jobs.filter(j => !['done', 'failed', 'removed'].includes(j.status));
|
|
const pendingJobs = jobs.filter(j => j.status === 'pending');
|
|
const processingJob = jobs.find(j => j.status === 'processing');
|
|
const count = activeJobs.length;
|
|
const queueBtn = document.getElementById('queue-btn');
|
|
|
|
// Update badge
|
|
if (count > 0) {
|
|
badge.textContent = count;
|
|
badge.classList.remove('d-none');
|
|
queueBtn.classList.add('queue-btn-active');
|
|
} else {
|
|
badge.classList.add('d-none');
|
|
queueBtn.classList.remove('queue-btn-active');
|
|
}
|
|
|
|
// Update generating animation and tooltip
|
|
if (processingJob) {
|
|
queueBtn.classList.add('queue-btn-generating');
|
|
queueBtn.title = `Generating: ${processingJob.label}`;
|
|
} else if (pendingJobs.length > 0) {
|
|
queueBtn.classList.remove('queue-btn-generating');
|
|
queueBtn.title = `${pendingJobs.length} job(s) queued`;
|
|
} else {
|
|
queueBtn.classList.remove('queue-btn-generating');
|
|
queueBtn.title = 'Generation Queue';
|
|
}
|
|
|
|
// Update modal count
|
|
if (modalCount) modalCount.textContent = jobs.length;
|
|
|
|
// Update Clear Queue button state
|
|
const clearBtn = document.getElementById('queue-clear-btn');
|
|
if (clearBtn) {
|
|
if (pendingJobs.length > 0) {
|
|
clearBtn.disabled = false;
|
|
clearBtn.title = `Clear ${pendingJobs.length} pending job(s)`;
|
|
} else {
|
|
clearBtn.disabled = true;
|
|
clearBtn.title = 'No pending jobs to clear';
|
|
}
|
|
}
|
|
|
|
// Render job list
|
|
if (!jobList) return;
|
|
if (jobs.length === 0) {
|
|
jobList.classList.add('d-none');
|
|
if (emptyMsg) emptyMsg.classList.remove('d-none');
|
|
return;
|
|
}
|
|
jobList.classList.remove('d-none');
|
|
if (emptyMsg) emptyMsg.classList.add('d-none');
|
|
|
|
jobList.innerHTML = '';
|
|
jobs.forEach(job => {
|
|
const li = document.createElement('li');
|
|
li.className = 'list-group-item d-flex align-items-center gap-2 py-2';
|
|
li.id = `queue-job-${job.id}`;
|
|
|
|
const statusInfo = STATUS_LABELS[job.status] || { text: job.status, cls: 'text-muted' };
|
|
|
|
// Status indicator
|
|
const statusDot = document.createElement('span');
|
|
statusDot.className = `queue-status-dot queue-status-${job.status}`;
|
|
li.appendChild(statusDot);
|
|
|
|
// Label
|
|
const label = document.createElement('span');
|
|
label.className = 'flex-grow-1 small';
|
|
label.textContent = job.label;
|
|
li.appendChild(label);
|
|
|
|
// Status text
|
|
const statusText = document.createElement('span');
|
|
statusText.className = `small ${statusInfo.cls}`;
|
|
statusText.textContent = statusInfo.text;
|
|
if (job.status === 'failed' && job.error) {
|
|
statusText.title = job.error;
|
|
statusText.style.cursor = 'help';
|
|
}
|
|
li.appendChild(statusText);
|
|
|
|
// Action buttons
|
|
const btnGroup = document.createElement('div');
|
|
btnGroup.className = 'd-flex gap-1';
|
|
|
|
if (job.status === 'pending') {
|
|
const pauseBtn = document.createElement('button');
|
|
pauseBtn.className = 'btn btn-xs btn-outline-secondary';
|
|
pauseBtn.textContent = '⏸';
|
|
pauseBtn.title = 'Pause';
|
|
pauseBtn.onclick = () => queuePause(job.id);
|
|
btnGroup.appendChild(pauseBtn);
|
|
}
|
|
if (job.status === 'paused') {
|
|
const resumeBtn = document.createElement('button');
|
|
resumeBtn.className = 'btn btn-xs btn-outline-success';
|
|
resumeBtn.textContent = '▶';
|
|
resumeBtn.title = 'Resume';
|
|
resumeBtn.onclick = () => queuePause(job.id);
|
|
btnGroup.appendChild(resumeBtn);
|
|
}
|
|
if (['pending', 'paused', 'failed'].includes(job.status)) {
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'btn btn-xs btn-outline-danger';
|
|
removeBtn.textContent = '✕';
|
|
removeBtn.title = 'Remove';
|
|
removeBtn.onclick = () => queueRemove(job.id);
|
|
btnGroup.appendChild(removeBtn);
|
|
}
|
|
|
|
li.appendChild(btnGroup);
|
|
jobList.appendChild(li);
|
|
});
|
|
}
|
|
|
|
async function fetchQueue() {
|
|
try {
|
|
const resp = await fetch('/api/queue');
|
|
const data = await resp.json();
|
|
renderQueue(data.jobs || []);
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function queueRemove(jobId) {
|
|
try {
|
|
await fetch(`/api/queue/${jobId}/remove`, { method: 'POST' });
|
|
fetchQueue();
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function queuePause(jobId) {
|
|
try {
|
|
await fetch(`/api/queue/${jobId}/pause`, { method: 'POST' });
|
|
fetchQueue();
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function queueClearAll() {
|
|
if (!confirm('Clear all pending jobs from the queue? The current generation will continue.')) {
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch('/api/queue/clear', { method: 'POST' });
|
|
const data = await resp.json();
|
|
if (data.removed_count > 0) {
|
|
alert(`Cleared ${data.removed_count} pending job(s) from the queue.`);
|
|
} else {
|
|
alert('No pending jobs to clear.');
|
|
}
|
|
fetchQueue();
|
|
} catch (e) {
|
|
alert('Error clearing queue: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Make queueClearAll globally accessible
|
|
window.queueClearAll = queueClearAll;
|
|
|
|
// Poll queue every 2 seconds
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
fetchQueue();
|
|
setInterval(fetchQueue, 2000);
|
|
|
|
// Refresh when modal opens
|
|
const queueModal = document.getElementById('queueModal');
|
|
if (queueModal) {
|
|
queueModal.addEventListener('show.bs.modal', fetchQueue);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
<script>
|
|
// ---- Gallery Navigation System ----
|
|
(function() {
|
|
let galleryImages = [];
|
|
let currentIndex = 0;
|
|
let galleryModal = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const modalEl = document.getElementById('galleryModal');
|
|
if (modalEl) {
|
|
galleryModal = new bootstrap.Modal(modalEl);
|
|
|
|
// Previous button
|
|
document.getElementById('gallery-prev').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
navigateGallery(-1);
|
|
});
|
|
|
|
// Next button
|
|
document.getElementById('gallery-next').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
navigateGallery(1);
|
|
});
|
|
|
|
// Keyboard navigation
|
|
modalEl.addEventListener('keydown', (e) => {
|
|
if (e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
navigateGallery(-1);
|
|
} else if (e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
navigateGallery(1);
|
|
} else if (e.key === 'Escape') {
|
|
galleryModal.hide();
|
|
}
|
|
});
|
|
|
|
// Click on image to go next (optional convenience)
|
|
document.getElementById('galleryImage').addEventListener('click', () => {
|
|
navigateGallery(1);
|
|
});
|
|
}
|
|
});
|
|
|
|
function navigateGallery(direction) {
|
|
if (galleryImages.length === 0) return;
|
|
|
|
currentIndex += direction;
|
|
|
|
// Wrap around
|
|
if (currentIndex < 0) {
|
|
currentIndex = galleryImages.length - 1;
|
|
} else if (currentIndex >= galleryImages.length) {
|
|
currentIndex = 0;
|
|
}
|
|
|
|
updateGalleryImage();
|
|
}
|
|
|
|
function updateGalleryImage() {
|
|
const img = document.getElementById('galleryImage');
|
|
const counter = document.getElementById('gallery-counter');
|
|
|
|
if (galleryImages[currentIndex]) {
|
|
img.src = galleryImages[currentIndex];
|
|
counter.textContent = `${currentIndex + 1} / ${galleryImages.length}`;
|
|
}
|
|
}
|
|
|
|
// Global function to open gallery
|
|
window.openGallery = function(images, startIndex = 0) {
|
|
galleryImages = images || [];
|
|
currentIndex = startIndex;
|
|
|
|
if (galleryImages.length === 0) return;
|
|
|
|
updateGalleryImage();
|
|
galleryModal.show();
|
|
};
|
|
|
|
// Global function to register gallery images from a container
|
|
window.registerGallery = function(containerSelector, imageSelector) {
|
|
const container = document.querySelector(containerSelector);
|
|
if (!container) return [];
|
|
|
|
const images = [];
|
|
container.querySelectorAll(imageSelector).forEach((img, index) => {
|
|
const src = img.src || img.dataset.src;
|
|
if (src) {
|
|
images.push(src);
|
|
img.style.cursor = 'pointer';
|
|
img.addEventListener('click', () => {
|
|
openGallery(images, index);
|
|
});
|
|
}
|
|
});
|
|
return images;
|
|
};
|
|
})();
|
|
</script>
|
|
<script>
|
|
// Seed input: clear button and auto-populate from generation result
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const seedInput = document.getElementById('seed-input');
|
|
const seedClearBtn = document.getElementById('seed-clear-btn');
|
|
if (seedClearBtn && seedInput) {
|
|
seedClearBtn.addEventListener('click', () => { seedInput.value = ''; });
|
|
}
|
|
});
|
|
// Global helper: update seed input after a generation job completes
|
|
function updateSeedFromResult(jobResult) {
|
|
const seedInput = document.getElementById('seed-input');
|
|
if (seedInput && jobResult?.seed != null) {
|
|
seedInput.value = jobResult.seed;
|
|
}
|
|
}
|
|
</script>
|
|
<script>
|
|
// Endless generation mode
|
|
(function() {
|
|
let _endless = false;
|
|
let _endlessCount = 0;
|
|
let _endlessAbort = null;
|
|
|
|
window._endlessActive = () => _endless;
|
|
|
|
window._endlessStart = function() {
|
|
const form = document.getElementById('generate-form');
|
|
const btn = document.getElementById('endless-btn');
|
|
const stopBtn = document.getElementById('endless-stop-btn');
|
|
const counter = document.getElementById('endless-counter');
|
|
const progressContainer = document.getElementById('progress-container');
|
|
const progressBar = document.getElementById('progress-bar');
|
|
const progressLabel = document.getElementById('progress-label');
|
|
if (!form || !btn) return;
|
|
|
|
_endless = true;
|
|
_endlessCount = 0;
|
|
_endlessAbort = new AbortController();
|
|
btn.classList.add('d-none');
|
|
stopBtn.classList.remove('d-none');
|
|
counter.classList.remove('d-none');
|
|
counter.textContent = '0 generated';
|
|
|
|
// Disable single generate button during endless
|
|
const genBtn = form.querySelector('button[value="preview"]');
|
|
if (genBtn) genBtn.disabled = true;
|
|
|
|
(async function loop() {
|
|
while (_endless) {
|
|
// Clear seed for random each time
|
|
const seedInput = document.getElementById('seed-input');
|
|
if (seedInput) seedInput.value = '';
|
|
|
|
const formData = new FormData(form);
|
|
formData.set('action', 'preview');
|
|
|
|
// Show progress
|
|
if (progressContainer) {
|
|
progressContainer.classList.remove('d-none');
|
|
progressBar.style.width = '100%';
|
|
progressBar.textContent = '';
|
|
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
progressLabel.textContent = `Endless #${_endlessCount + 1} — Queuing…`;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(form.getAttribute('action'), {
|
|
method: 'POST', body: formData,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
signal: _endlessAbort.signal
|
|
});
|
|
const data = await response.json();
|
|
if (data.error) { console.error('Endless error:', data.error); break; }
|
|
|
|
if (progressLabel) progressLabel.textContent = `Endless #${_endlessCount + 1} — Generating…`;
|
|
|
|
// Poll for completion
|
|
const jobResult = await new Promise((resolve, reject) => {
|
|
const poll = setInterval(async () => {
|
|
if (!_endless) { clearInterval(poll); reject(new Error('stopped')); return; }
|
|
try {
|
|
const resp = await fetch(`/api/queue/${data.job_id}/status`);
|
|
const status = await resp.json();
|
|
if (status.status === 'done') { clearInterval(poll); resolve(status); }
|
|
else if (status.status === 'failed' || status.status === 'removed') {
|
|
clearInterval(poll); reject(new Error(status.error || 'Job failed'));
|
|
} else if (status.status === 'processing' && progressLabel) {
|
|
progressLabel.textContent = `Endless #${_endlessCount + 1} — Generating…`;
|
|
}
|
|
} catch (err) { /* keep polling */ }
|
|
}, 1500);
|
|
});
|
|
|
|
_endlessCount++;
|
|
counter.textContent = `${_endlessCount} generated`;
|
|
|
|
// Update preview via page-specific handler
|
|
if (jobResult.result?.image_url && window._onEndlessResult) {
|
|
window._onEndlessResult(jobResult);
|
|
}
|
|
updateSeedFromResult(jobResult.result);
|
|
|
|
} catch (err) {
|
|
if (err.name === 'AbortError' || err.message === 'stopped') break;
|
|
console.error('Endless generation error:', err);
|
|
break;
|
|
}
|
|
}
|
|
// Cleanup
|
|
window._endlessStop();
|
|
})();
|
|
};
|
|
|
|
window._endlessStop = function() {
|
|
_endless = false;
|
|
if (_endlessAbort) { _endlessAbort.abort(); _endlessAbort = null; }
|
|
const btn = document.getElementById('endless-btn');
|
|
const stopBtn = document.getElementById('endless-stop-btn');
|
|
const progressContainer = document.getElementById('progress-container');
|
|
const progressBar = document.getElementById('progress-bar');
|
|
const form = document.getElementById('generate-form');
|
|
if (btn) btn.classList.remove('d-none');
|
|
if (stopBtn) stopBtn.classList.add('d-none');
|
|
if (progressContainer) progressContainer.classList.add('d-none');
|
|
if (progressBar) progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
|
const genBtn = form?.querySelector('button[value="preview"]');
|
|
if (genBtn) genBtn.disabled = false;
|
|
};
|
|
|
|
// Stop on page leave
|
|
window.addEventListener('beforeunload', () => { if (_endless) window._endlessStop(); });
|
|
})();
|
|
</script>
|
|
{% block scripts %}{% endblock %}
|
|
</body>
|
|
</html>
|