- 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
345 lines
16 KiB
HTML
345 lines
16 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&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>
|
|
<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">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>
|
|
<!-- 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="Danbooru MCP: checking…">
|
|
<span class="status-dot status-checking"></span>
|
|
<span class="status-label d-none d-xl-inline">MCP</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="default-checkpoint-bar border-bottom mb-4">
|
|
<div class="container d-flex align-items-center gap-2 py-2">
|
|
<small class="text-muted text-nowrap">Default checkpoint:</small>
|
|
<select id="defaultCheckpointSelect" class="form-select form-select-sm" style="max-width: 320px;">
|
|
<option value="">— workflow default —</option>
|
|
{% for ckpt in all_checkpoints %}
|
|
<option value="{{ ckpt.checkpoint_path }}"{% if ckpt.checkpoint_path == default_checkpoint_path %} selected{% endif %}>{{ ckpt.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<small id="checkpointSaveStatus" class="text-muted" style="opacity:0;transition:opacity 0.5s">Saved</small>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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));
|
|
|
|
const ckptSelect = document.getElementById('defaultCheckpointSelect');
|
|
const saveStatus = document.getElementById('checkpointSaveStatus');
|
|
if (ckptSelect) {
|
|
ckptSelect.addEventListener('change', () => {
|
|
fetch('/set_default_checkpoint', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
|
body: 'checkpoint_path=' + encodeURIComponent(ckptSelect.value)
|
|
}).then(() => {
|
|
saveStatus.style.opacity = '1';
|
|
setTimeout(() => { saveStatus.style.opacity = '0'; }, 1500);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
</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-mcp', url: '/api/status/mcp', label: 'Danbooru MCP' },
|
|
];
|
|
|
|
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');
|
|
const tooltipText = label + ': ' + (ok ? 'online' : 'offline');
|
|
el.setAttribute('data-bs-title', tooltipText);
|
|
el.setAttribute('title', tooltipText);
|
|
// Refresh tooltip instance if already initialised
|
|
const tip = bootstrap.Tooltip.getInstance(el);
|
|
if (tip) {
|
|
tip.setContent({ '.tooltip-inner': tooltipText });
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function pollAll() {
|
|
services.forEach(pollService);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
pollAll();
|
|
setInterval(pollAll, 30000); // re-check every 30 s
|
|
});
|
|
})();
|
|
</script>
|
|
{% block scripts %}{% endblock %}
|
|
</body>
|
|
</html>
|