Files
bookapp/templates/run_details.html
Mike Wichers af2050160e Add run deletion with filesystem cleanup
- New POST /run/<id>/delete route removes run from DB and deletes run directory
- Only allows deletion of non-active runs (blocks running/queued)
- Delete Run button shown in run_details.html header for non-active runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:04:44 -05:00

551 lines
27 KiB
HTML

{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2>
<p class="text-muted mb-0">Project: <a href="{{ url_for('project.view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
</div>
<div>
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
<i class="fas fa-scroll me-2"></i>Show Bible
</button>
<a href="{{ url_for('run.download_bible', id=run.id) }}" class="btn btn-outline-info me-2" title="Download the project bible (JSON) used for this run.">
<i class="fas fa-file-download me-2"></i>Download Bible
</a>
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#modifyRunModal" data-bs-toggle="tooltip" title="Create a new run based on this one, but with different instructions (e.g. 'Make it darker').">
<i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
</button>
{% if run.status not in ['running', 'queued'] %}
<form action="{{ url_for('run.delete_run', id=run.id) }}" method="POST" class="d-inline ms-2"
onsubmit="return confirm('Delete Run #{{ run.id }} and all its files? This cannot be undone.');">
<button type="submit" class="btn btn-outline-danger">
<i class="fas fa-trash me-2"></i>Delete Run
</button>
</form>
{% endif %}
<a href="{{ url_for('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary ms-2">Back to Project</a>
</div>
</div>
<!-- Collapsible Bible Viewer -->
<div class="collapse mb-4" id="bibleCollapse">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-book-open me-2"></i>Project Bible</h5>
</div>
<div class="card-body">
{% if bible %}
<ul class="nav nav-tabs" id="bibleTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="meta-tab" data-bs-toggle="tab" data-bs-target="#meta" type="button" role="tab">Metadata</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="chars-tab" data-bs-toggle="tab" data-bs-target="#chars" type="button" role="tab">Characters</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="plot-tab" data-bs-toggle="tab" data-bs-target="#plot" type="button" role="tab">Plot</button>
</li>
</ul>
<div class="tab-content p-3 border border-top-0">
<!-- Metadata Tab -->
<div class="tab-pane fade show active" id="meta" role="tabpanel">
<dl class="row mb-0">
{% for k, v in bible.project_metadata.items() %}
{% if k not in ['style', 'length_settings', 'author_details'] %}
<dt class="col-sm-3 text-capitalize">{{ k.replace('_', ' ') }}</dt>
<dd class="col-sm-9">{{ v }}</dd>
{% endif %}
{% endfor %}
{% if bible.project_metadata.style %}
<dt class="col-sm-12 mt-3 border-bottom pb-2">Style Settings</dt>
{% for k, v in bible.project_metadata.style.items() %}
<dt class="col-sm-3 text-capitalize mt-2">{{ k.replace('_', ' ') }}</dt>
<dd class="col-sm-9 mt-2">{{ v|join(', ') if v is sequence and v is not string else v }}</dd>
{% endfor %}
{% endif %}
</dl>
</div>
<!-- Characters Tab -->
<div class="tab-pane fade" id="chars" role="tabpanel">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light"><tr><th>Name</th><th>Role</th><th>Description</th></tr></thead>
<tbody>
{% for c in bible.characters %}
<tr>
<td class="fw-bold">{{ c.name }}</td>
<td><span class="badge bg-secondary">{{ c.role }}</span></td>
<td class="small">{{ c.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Plot Tab -->
<div class="tab-pane fade" id="plot" role="tabpanel">
{% for book in bible.books %}
<div class="mb-3">
<h6 class="fw-bold text-primary">Book {{ book.book_number }}: {{ book.title }}</h6>
<p class="fst-italic small text-muted">{{ book.manual_instruction }}</p>
<ol class="list-group list-group-numbered">
{% for beat in book.plot_beats %}
<li class="list-group-item list-group-item-action border-0 py-1">{{ beat }}</li>
{% endfor %}
</ol>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-warning">Bible data not found for this project.</div>
{% endif %}
</div>
</div>
</div>
<!-- Status Bar -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-bold" id="status-text">Status: {{ run.status|title }}</span>
<div>
<span class="text-muted me-2" id="run-duration">{{ run.duration() }}</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="updateLog()" title="Manually refresh status">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
</div>
<div class="progress" style="height: 20px;">
<div id="status-bar" class="progress-bar {% if run.status == 'running' %}progress-bar-striped progress-bar-animated{% elif run.status == 'failed' %}bg-danger{% else %}bg-success{% endif %}"
role="progressbar" style="width: {% if run.status == 'completed' %}100%{% else %}{{ run.progress }}%{% endif %}">
{% if run.status == 'running' %}{{ run.progress }}%{% endif %}</div>
</div>
</div>
</div>
<!-- Generated Books in this Run -->
{% for book in books %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-book me-2"></i>{{ book.folder }}</h5>
</div>
<div class="card-body">
<div class="row">
<!-- Left Column: Cover Art -->
<div class="col-md-4 mb-3">
<div class="text-center">
{% if book.cover %}
<img src="{{ url_for('run.download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
{% else %}
<div class="alert alert-secondary py-5">
<i class="fas fa-image fa-3x mb-3"></i><br>No cover.
</div>
{% endif %}
{% if loop.first %}
<form action="{{ url_for('run.regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
<textarea name="feedback" class="form-control mb-2 form-control-sm" rows="1" placeholder="Cover Feedback..."></textarea>
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
<i class="fas fa-sync me-2"></i>Regenerate All
</button>
</form>
{% endif %}
</div>
</div>
<!-- Right Column: Blurb -->
<div class="col-md-8">
<h6 class="fw-bold">Back Cover Blurb</h6>
<div class="p-3 bg-light rounded mb-3">
{% if book.blurb %}
<p class="mb-0" style="white-space: pre-wrap;">{{ book.blurb }}</p>
{% else %}
<p class="text-muted fst-italic mb-0">No blurb generated.</p>
{% endif %}
</div>
<h6 class="fw-bold">Artifacts</h6>
<div class="d-flex flex-wrap gap-2">
{% for art in book.artifacts %}
<a href="{{ url_for('run.download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
<i class="fas fa-download me-1"></i> {{ art.name }}
</a>
{% else %}
<span class="text-muted small">No files found.</span>
{% endfor %}
<div class="mt-3">
<a href="{{ url_for('run.read_book', run_id=run.id, book_folder=book.folder) }}" class="btn btn-primary">
<i class="fas fa-book-reader me-2"></i>Read & Edit
</a>
<a href="{{ url_for('run.check_consistency', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-warning ms-2">
<i class="fas fa-search me-2"></i>Check Consistency
</a>
<button class="btn btn-warning ms-2" data-bs-toggle="modal" data-bs-target="#reviseBookModal{{ loop.index }}" title="Regenerate this book with changes, keeping others.">
<i class="fas fa-pencil-alt me-2"></i>Revise
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Revise Book Modal -->
<div class="modal fade" id="reviseBookModal{{ loop.index }}" tabindex="-1">
<div class="modal-dialog">
<form class="modal-content" action="{{ url_for('project.revise_book', run_id=run.id, book_folder=book.folder) }}" method="POST">
<div class="modal-header">
<h5 class="modal-title">Revise Book</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small">This will start a <strong>new run</strong>. All other books will be copied over, but this book will be regenerated based on your instructions.</p>
<div class="mb-3">
<label class="form-label">Instructions</label>
<textarea name="instruction" class="form-control" rows="4" placeholder="e.g. 'Change the ending', 'Make the pacing faster', 'Add a scene about X'." required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">Start Revision</button>
</div>
</form>
</div>
</div>
{% endfor %}
<div class="row mb-4">
<div class="col-6">
<div class="card shadow-sm text-center">
<div class="card-body">
<h6 class="text-muted">Total Cost</h6>
<h3 class="text-success" id="run-cost">${{ "%.4f"|format(run.cost) }}</h3>
</div>
</div>
</div>
<div class="col-6">
<div class="card shadow-sm text-center">
<div class="card-body">
<h6 class="text-muted">Artifacts</h6>
<h3>
{% if has_cover %}<i class="fas fa-check text-success me-2"></i>{% endif %}
{% if blurb %}<i class="fas fa-check text-success"></i>{% endif %}
</h3>
</div>
</div>
</div>
</div>
<!-- Tracking & Warnings -->
{% if tracking and (tracking.content_warnings or tracking.characters) %}
<div class="card shadow-sm mb-4 border-warning">
<div class="card-header bg-warning-subtle">
<h5 class="mb-0 text-warning-emphasis"><i class="fas fa-exclamation-triangle me-2"></i>Story Tracking & Warnings</h5>
</div>
<div class="card-body">
{% if tracking.content_warnings %}
<div class="mb-3">
<h6 class="fw-bold">Content Warnings Detected:</h6>
<div>
{% for w in tracking.content_warnings %}
<span class="badge bg-danger me-1 mb-1">{{ w }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% if tracking.characters %}
{# Check if any character actually has major events to display #}
{% set has_events = namespace(value=false) %}
{% for name, data in tracking.characters.items() %}
{% if data.major_events %}{% set has_events.value = true %}{% endif %}
{% endfor %}
{% if has_events.value %}
<h6 class="fw-bold">Major Character Events:</h6>
<div class="accordion" id="charEventsAcc">
{% for name, data in tracking.characters.items() %}
{% if data.major_events %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#ce-{{ loop.index }}">
{{ name }}
</button>
</h2>
<div id="ce-{{ loop.index }}" class="accordion-collapse collapse" data-bs-parent="#charEventsAcc">
<div class="accordion-body small py-2">
<ul class="mb-0 ps-3">
{% for evt in data.major_events %}
<li>{{ evt }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
<!-- Live Status Panel -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-3 flex-wrap">
<span class="small text-muted fw-semibold">Poll:</span>
<span id="poll-state" class="badge bg-secondary">Initializing...</span>
<span class="small text-muted">Last update:</span>
<span id="last-update-time" class="small fw-bold text-info"></span>
<span id="db-diagnostics" class="small text-muted"></span>
</div>
<button class="btn btn-sm btn-outline-info py-0 px-2" onclick="forceRefresh()" title="Immediately trigger a new poll request">
<i class="fas fa-bolt me-1"></i>Force Refresh
</button>
</div>
<div id="poll-error-msg" class="small text-danger mt-1" style="display:none;"></div>
</div>
</div>
<!-- Collapsible Log -->
<div class="card shadow-sm">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#logCollapse">
<h5 class="mb-0"><i class="fas fa-terminal me-2"></i>System Log</h5>
<span class="badge bg-secondary" id="log-badge">Click to Toggle</span>
</div>
<div id="logCollapse" class="collapse {% if run.status == 'running' %}show{% endif %}">
<div class="card-body bg-dark p-0">
<pre id="console-log" class="console-log m-0 p-3" style="color: #00ff00; background-color: #1e1e1e; height: 400px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">{{ log_content or "Waiting for logs..." }}</pre>
</div>
</div>
</div>
</div>
</div>
<!-- Modify Run Modal -->
<div class="modal fade" id="modifyRunModal" tabindex="-1">
<div class="modal-dialog">
<form class="modal-content" action="/run/{{ run.id }}/restart" method="POST">
<div class="modal-header">
<h5 class="modal-title">Modify & Re-run</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small">This will create a <strong>new run</strong> based on this one. You can ask the AI to change the plot, style, or characters.</p>
<div class="mb-3">
<label class="form-label">Instructions / Feedback</label>
<textarea name="feedback" class="form-control" rows="4" placeholder="e.g. 'Make the ending happier', 'Change the setting to Mars', 'Rewrite Chapter 1 to be faster paced'." required></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="keep_cover" id="keepCoverCheck" checked>
<label class="form-check-label" for="keepCoverCheck">Keep existing cover art (if possible)</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="force_regenerate" id="forceRegenCheck">
<label class="form-check-label" for="forceRegenCheck">Force Regenerate (Don't copy text from previous run)</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Start New Run</button>
</div>
</form>
</div>
</div>
<script>
const runId = {{ run.id }};
const initialStatus = "{{ run.status }}";
const consoleEl = document.getElementById('console-log');
const statusText = document.getElementById('status-text');
const statusBar = document.getElementById('status-bar');
const costEl = document.getElementById('run-cost');
let lastLog = '';
let pollTimer = null;
let countdownInterval = null;
// Phase → colour mapping (matches utils.log phase labels)
const PHASE_COLORS = {
'WRITER': '#4fc3f7',
'ARCHITECT': '#81c784',
'TIMING': '#78909c',
'SYSTEM': '#fff176',
'TRACKER': '#ce93d8',
'RESUME': '#ffb74d',
'SERIES': '#64b5f6',
'ENRICHER': '#4dd0e1',
'HARVESTER': '#ff8a65',
'EDITOR': '#f48fb1',
};
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function colorizeLog(logText) {
if (!logText) return '';
return logText.split('\n').map(line => {
const m = line.match(/^(\[[\d:]+\])\s+(\w+)\s+\|(.*)$/);
if (!m) return '<span style="color:#666">' + escapeHtml(line) + '</span>';
const [, ts, phase, msg] = m;
const color = PHASE_COLORS[phase] || '#aaaaaa';
return '<span style="color:#555">' + escapeHtml(ts) + '</span> '
+ '<span style="color:' + color + ';font-weight:bold">' + phase.padEnd(14) + '</span>'
+ '<span style="color:#ccc">|' + escapeHtml(msg) + '</span>';
}).join('\n');
}
function getCurrentPhase(logText) {
if (!logText) return '';
const lines = logText.split('\n').filter(l => l.trim());
for (let k = lines.length - 1; k >= 0; k--) {
const m = lines[k].match(/\]\s+(\w+)\s+\|/);
if (m) return m[1];
}
return '';
}
// --- Live Status Panel helpers ---
function clearCountdown() {
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
}
function setPollState(text, badgeClass) {
const el = document.getElementById('poll-state');
if (el) { el.className = 'badge ' + badgeClass; el.innerText = text; }
}
function setPollError(msg) {
const el = document.getElementById('poll-error-msg');
if (!el) return;
if (msg) { el.innerText = 'Last error: ' + msg; el.style.display = ''; }
else { el.innerText = ''; el.style.display = 'none'; }
}
function startWaitCountdown(seconds, isError) {
clearCountdown();
let rem = seconds;
const cls = isError ? 'bg-danger' : 'bg-secondary';
const prefix = isError ? 'Error — retry in' : 'Waiting';
setPollState(prefix + ' (' + rem + 's)', cls);
countdownInterval = setInterval(() => {
rem--;
if (rem <= 0) { clearCountdown(); }
else { setPollState(prefix + ' (' + rem + 's)', cls); }
}, 1000);
}
function forceRefresh() {
clearCountdown();
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
updateLog();
}
// --- Main polling function ---
function updateLog() {
setPollState('Requesting...', 'bg-primary');
fetch(`/run/${runId}/status`)
.then(response => response.json())
.then(data => {
// Update "Last Successful Update" timestamp
const now = new Date();
const lastUpdateEl = document.getElementById('last-update-time');
if (lastUpdateEl) lastUpdateEl.innerText = now.toLocaleTimeString();
// Update DB diagnostics
const diagEl = document.getElementById('db-diagnostics');
if (diagEl) {
const parts = [];
if (data.db_log_count !== undefined) parts.push('DB logs: ' + data.db_log_count);
if (data.latest_log_timestamp) parts.push('Latest: ' + String(data.latest_log_timestamp).substring(11, 19));
diagEl.innerText = parts.join(' | ');
}
// Clear any previous poll error
setPollError(null);
// Update Status Text + current phase
const statusLabel = data.status.charAt(0).toUpperCase() + data.status.slice(1);
if (data.status === 'running') {
const phase = getCurrentPhase(data.log);
statusText.innerText = 'Status: Running' + (phase ? ' — ' + phase : '');
} else {
statusText.innerText = 'Status: ' + statusLabel;
}
costEl.innerText = '$' + parseFloat(data.cost).toFixed(4);
// Update Status Bar
if (data.status === 'running' || data.status === 'queued') {
statusBar.className = "progress-bar progress-bar-striped progress-bar-animated";
statusBar.style.width = (data.percent || 5) + "%";
let label = (data.percent || 0) + "%";
if (data.status === 'running' && data.percent > 2 && data.start_time) {
const elapsed = (Date.now() / 1000) - data.start_time;
if (elapsed > 5) {
const remaining = (elapsed / (data.percent / 100)) - elapsed;
const m = Math.floor(remaining / 60);
const s = Math.floor(remaining % 60);
if (remaining > 0 && remaining < 86400) label += ` (~${m}m ${s}s)`;
}
}
statusBar.innerText = label;
} else if (data.status === 'failed') {
statusBar.className = "progress-bar bg-danger";
statusBar.style.width = "100%";
} else {
statusBar.className = "progress-bar bg-success";
statusBar.style.width = "100%";
statusBar.innerText = "";
}
// Update Log with phase colorization (only if changed to avoid scroll jitter)
if (lastLog !== data.log) {
lastLog = data.log;
const isScrolledToBottom = consoleEl.scrollHeight - consoleEl.clientHeight <= consoleEl.scrollTop + 50;
consoleEl.innerHTML = colorizeLog(data.log);
if (isScrolledToBottom) {
consoleEl.scrollTop = consoleEl.scrollHeight;
}
}
// Schedule next poll or stop
if (data.status === 'running' || data.status === 'queued') {
startWaitCountdown(2, false);
pollTimer = setTimeout(updateLog, 2000);
} else {
setPollState('Idle', 'bg-success');
// If the run was active when we loaded the page, reload to show artifacts
if (initialStatus === 'running' || initialStatus === 'queued') {
window.location.reload();
}
}
})
.catch(err => {
console.error("Polling failed:", err);
const errMsg = err.message || String(err);
setPollError(errMsg);
startWaitCountdown(5, true);
pollTimer = setTimeout(updateLog, 5000);
});
}
// Start polling
updateLog();
</script>
{% endblock %}