- web/tasks.py: db_log_callback now writes non-OperationalError exceptions to data/app.log for visibility - web/tasks.py: generate_book_task restructured with try...finally to guarantee final status update — run can never be left in 'running' state if worker crashes - templates/project.html: added .catch() to fetchLog() with console.error + polling resume on failure; added manual Refresh button to status bar - templates/run_details.html: improved .catch() in updateLog() with descriptive message + 5s retry; added manual Refresh button to status bar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
457 lines
23 KiB
HTML
457 lines
23 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>
|
|
<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>
|
|
<a href="{{ url_for('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary">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 %}
|
|
|
|
<!-- 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 = '';
|
|
|
|
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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 '';
|
|
}
|
|
|
|
function updateLog() {
|
|
fetch(`/run/${runId}/status`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// Poll if running
|
|
if (data.status === 'running' || data.status === 'queued') {
|
|
setTimeout(updateLog, 2000);
|
|
} else {
|
|
// If the run was active when we loaded the page, reload now that it's finished to show artifacts
|
|
if (initialStatus === 'running' || initialStatus === 'queued') {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Polling failed:", err);
|
|
setTimeout(updateLog, 5000);
|
|
});
|
|
}
|
|
|
|
// Start polling
|
|
updateLog();
|
|
</script>
|
|
{% endblock %} |