feat(transcribe): UX improvements — progress bar, stats row, copy btn, char counter, batch errors

- Vocab textarea now shows live 0/500 char counter (turns amber at 450+)
- Animated progress bar during transcription; determinate for multi-clip, indeterminate for single
- Results card shows inline stats row (duration, language, speakers) and AI cleanup badge
- Copy button + Download TXT moved above transcript box; SRT/VTT remain below
- Speaker role legend repeats inside Segments panel for easy cross-reference
- Batch errors no longer halt the queue; remaining clips continue, failed files named in status bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 08:21:19 +02:00
parent d1ad19d3c2
commit 850937e4b3
3 changed files with 474 additions and 31 deletions
+262 -30
View File
@@ -381,8 +381,10 @@ let lastTimelineEvents = [];
let lastTimelineEventsOriginal = [];
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
let lastTranscriptData = null;
let lastRedactedText = null;
let lastRunEngine = null;
let lastRedactedText = null;
let lastOriginalText = '';
let lastRedactPayload = null;
let lastRunEngine = null;
const VOCAB_PRESETS = {
barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører',
@@ -933,11 +935,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
els.results?.addEventListener('click', (e) => {
if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents);
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
if (e.target.closest('#dlSrt')) downloadTranscriptSrt();
if (e.target.closest('#dlVtt')) downloadTranscriptVtt();
if (e.target.closest('#txCopy')) copyTranscriptText();
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
if (e.target.closest('#dlSrt')) downloadTranscriptSrt();
if (e.target.closest('#dlVtt')) downloadTranscriptVtt();
if (e.target.closest('#rdlCopy')) copyRedactedText();
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
if (e.target.closest('#rdlDocx')) downloadRedactedDocx();
});
const activeTool = document.body.dataset.activeTool || state.activeTool;
@@ -1043,14 +1046,16 @@ async function runTool(event) {
payload.limit = 7;
}
if (state.activeTool === 'redact') {
payload.mode = currentRedactionMode();
payload.region = currentRedactionRegion();
payload.aliases = getAliases();
payload.engine = currentRedactEngine();
payload.output_format = currentOutputFormat();
lastOriginalText = text;
payload.mode = currentRedactionMode();
payload.region = currentRedactionRegion();
payload.aliases = getAliases();
payload.engine = currentRedactEngine();
payload.output_format = currentOutputFormat();
payload.keep_officials = currentKeepOfficials();
payload.exempt_names = getExemptNames();
payload.redact_types = currentRedactTypes();
payload.exempt_names = getExemptNames();
payload.redact_types = currentRedactTypes();
lastRedactPayload = { ...payload };
}
if (state.activeTool === 'timeline') {
payload.engine = currentTimelineEngine();
@@ -1330,6 +1335,105 @@ function renderResults(data) {
}
}
// ── Redact: tag colour helpers ─────────────────────────────────────────────
function tagColorClass(root) {
if (/^(FATHER|MOTHER|CHILD|GRANDPARENT|SIBLING|ATTORNEY|JUDGE|CASEWORKER|EXPERT_WITNESS|PERSON)/.test(root)) return 'person';
if (root === 'ORG') return 'org';
if (root === 'PLACE') return 'place';
if (/^(DATE|DOB)/.test(root)) return 'date';
return 'id';
}
function highlightRedactedText(text) {
const escaped = escapeHtml(text);
return escaped.replace(/\[([A-Z][A-Z0-9_-]*)(?::\s*([^\]]+))?\]/g, (_m, root, suffix) => {
const cls = tagColorClass(root);
const display = suffix ? `[${root}: ${suffix}]` : `[${root}]`;
return `<span class="redact-tag redact-tag--${cls}">${display}</span>`;
});
}
function renderRedactionInventory(redactionMap, entityCounts) {
const map = redactionMap || {};
const counts = entityCounts || {};
const entries = [];
for (const [tag, info] of Object.entries(map)) {
const root = tag.replace(/^\[|\]$/g, '').split(':')[0].trim();
entries.push({ tag, originals: info.originals || [], occurrences: info.occurrences || 0, cls: tagColorClass(root) });
}
const mappedTypes = new Set(Object.values(map).map(e => e.type));
for (const [type, count] of Object.entries(counts)) {
if (Number(count) > 0 && !mappedTypes.has(type)) {
entries.push({ tag: type, originals: [], occurrences: Number(count), cls: tagColorClass(type.toUpperCase()) });
}
}
if (!entries.length) return '';
const rows = entries.map(e => {
const tagSpan = `<span class="redact-tag redact-tag--${e.cls}">${escapeHtml(e.tag)}</span>`;
const originalsHtml = e.originals.length
? `<span class="inv-originals">→ ${e.originals.map(o => `<em>${escapeHtml(o)}</em>`).join(', ')}</span> `
: '';
const countHtml = e.occurrences > 0 ? `<span class="inv-count">${e.occurrences}×</span>` : '';
return `<li>${tagSpan} ${originalsHtml}${countHtml}</li>`;
}).join('');
return `<details class="redact-inventory" open>
<summary>Redaction inventory <span class="inv-badge">${entries.length}</span></summary>
<ul class="inv-list">${rows}</ul>
</details>`;
}
async function rerunWithBetterEngine() {
if (!lastRedactPayload) return;
const btn = document.getElementById('rerunBetterBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Running…'; }
const payload = { ...lastRedactPayload, engine: 'azure_full' };
lastRedactPayload = payload;
setBusy(true);
renderTrace([{ label: 'Re-run with gpt-4o', detail: 'Submitting same text with Azure gpt-4o engine.', status: 'running' }]);
try {
const data = await postJson('api/redact.php', payload);
if (!data.ok) throw new Error(data.error?.message || 'Re-run failed.');
renderResults(data);
renderTrace(data.trace || []);
els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
} catch (error) {
els.status.textContent = error.message;
renderTrace([{ label: 'Tool error', detail: error.message, status: 'warning' }]);
} finally {
setBusy(false);
}
}
function setupRedactViewToggle() {
const pre = document.getElementById('redactOutputPre');
const btnRedacted = document.getElementById('viewRedacted');
const btnOriginal = document.getElementById('viewOriginal');
const rerunBtn = document.getElementById('rerunBetterBtn');
if (btnRedacted && pre) {
btnRedacted.addEventListener('click', () => {
pre.innerHTML = highlightRedactedText(lastRedactedText || '');
btnRedacted.classList.add('is-active');
if (btnOriginal) btnOriginal.classList.remove('is-active');
});
}
if (btnOriginal && pre) {
btnOriginal.addEventListener('click', () => {
pre.textContent = lastOriginalText || '';
btnOriginal.classList.add('is-active');
if (btnRedacted) btnRedacted.classList.remove('is-active');
});
}
if (rerunBtn) rerunBtn.addEventListener('click', rerunWithBetterEngine);
}
// ── Main finding renderer ───────────────────────────────────────────────────
function renderMainFinding(data) {
if (data.tool === 'ask') {
return `<p class="answer">${escapeHtml(data.answer || data.what_we_found || '')}</p>`;
@@ -1337,12 +1441,26 @@ function renderMainFinding(data) {
if (data.tool === 'redact') {
lastRedactedText = data.redacted_text || '';
const t = (k) => currentRedactT(k) || k;
const viewToggle = `<div class="redact-view-toggle">
<button type="button" class="view-btn is-active" id="viewRedacted">Redacted</button>
<button type="button" class="view-btn" id="viewOriginal">Original</button>
</div>`;
const inventoryHtml = renderRedactionInventory(data.redaction_map, data.entity_counts);
const isNotBestEngine = data.engine_used && data.engine_used !== 'Azure gpt-4o' && data.engine_used !== 'gpt-4o';
const upgradeBtn = isNotBestEngine
? `<button type="button" class="upgrade-engine-btn" id="rerunBetterBtn">Re-run with gpt-4o for higher accuracy →</button>`
: '';
const dlRow = `<div class="redact-downloads">
<button type="button" class="redact-dl-btn" id="rdlCopy">${t('redactCopy')}</button>
<button type="button" class="redact-dl-btn" id="rdlTxt">${t('redactDownloadTxt')}</button>
<button type="button" class="redact-dl-btn" id="rdlDocx">${t('redactDownloadDocx')}</button>
</div>`;
return `<pre class="redacted-output">${escapeHtml(lastRedactedText)}</pre>${renderEntityCounts(data.entity_counts)}${dlRow}`;
return `${viewToggle}<pre class="redacted-output" id="redactOutputPre">${highlightRedactedText(lastRedactedText)}</pre>${inventoryHtml}${upgradeBtn}${dlRow}`;
}
if (data.tool === 'timeline') {
lastTimelineEventsOriginal = data.events || [];
@@ -1520,6 +1638,36 @@ function currentTask() {
return el ? el.value : 'transcribe';
}
function buildTranscribeError(err, file) {
const name = file?.name ?? 'file';
const sizeMB = file?.size ? (file.size / 1024 / 1024).toFixed(1) : null;
let reason = err?.message || 'Unknown error';
if (/too large|size/i.test(reason) || reason.includes('413'))
reason = `File too large${sizeMB ? ` (${sizeMB} MB)` : ''}`;
else if (/format|unsupported|415/i.test(reason))
reason = 'Unsupported audio format';
else if (/timeout|timed out|504/i.test(reason))
reason = 'Request timed out — try a shorter clip';
else if (/50[0-9]/.test(reason))
reason = 'Server error';
return `${name}: ${reason}`;
}
function showTranscribeProgress(clip, total) {
if (!els.results) return;
const pct = total > 1 ? Math.round(((clip - 1) / total) * 100) : null;
const label = total > 1 ? `Clip ${clip} / ${total}` : 'Transcribing…';
const barClass = pct !== null ? '' : ' is-indeterminate';
const barStyle = pct !== null ? ` style="width:${pct}%"` : '';
els.results.innerHTML = `
<div class="transcribe-progress-wrap">
<div class="transcribe-progress-track">
<div class="transcribe-progress-fill${barClass}"${barStyle}></div>
</div>
<p class="transcribe-progress-label">${escapeHtml(label)}</p>
</div>`;
}
async function runTranscribe() {
if (!audioQueue.length) {
els.status.textContent = currentUiT('noFileSelected');
@@ -1535,7 +1683,7 @@ async function runTranscribe() {
const total = audioQueue.length;
// Reset all items to pending before starting
audioQueue.forEach((item) => { item.status = 'pending'; item.result = null; });
audioQueue.forEach((item) => { item.status = 'pending'; item.result = null; item.errorMsg = null; });
renderAudioQueue();
let cumulativeOffset = 0;
@@ -1549,6 +1697,9 @@ async function runTranscribe() {
item.status = 'processing';
renderAudioQueue();
// Show progress bar
showTranscribeProgress(i + 1, total);
const startTime = Date.now();
let elapsed = 0;
const clipLabel = currentUiT('clipLabel', i + 1, total);
@@ -1609,17 +1760,25 @@ async function runTranscribe() {
} catch (err) {
clearInterval(timer);
item.status = 'error';
item.errorMsg = buildTranscribeError(err, item.file);
renderAudioQueue();
els.status.textContent = `${clipLabel}: ${err.message}`;
renderTrace([{ label: currentUiT('errorLabel', clipLabel), detail: err.message, status: 'warning' }]);
setBusy(false);
return;
// Continue processing remaining clips rather than halting
}
renderAudioQueue();
}
// Merge results
// Merge results from successful clips
const errorItems = audioQueue.filter((it) => it.status === 'error');
if (errorItems.length && !lastResult) {
// All clips failed
const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
els.status.textContent = `Failed: ${errList}`;
renderTrace([{ label: 'Transcription failed', detail: errList, status: 'warning' }]);
setBusy(false);
return;
}
const merged = {
...lastResult,
transcript: allTranscripts.join('\n\n'),
@@ -1636,7 +1795,12 @@ async function runTranscribe() {
const totalMin = Math.floor(totalSec / 60);
const remSec = totalSec % 60;
const durLabel = totalMin > 0 ? `${totalMin}m ${remSec}s` : `${totalSec}s`;
els.status.textContent = currentUiT('done', total, durLabel);
if (errorItems.length) {
const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
els.status.textContent = `Done (${errorItems.length} failed: ${errList})`;
} else {
els.status.textContent = currentUiT('done', total, durLabel);
}
setBusy(false);
}
@@ -1662,6 +1826,7 @@ function renderTranscriptResults(data) {
const speakerOrder = [...new Set(segments.filter((s) => s.speaker).map((s) => s.speaker))];
// Speaker role tags (shown above transcript)
const rolesHtml = speakerOrder.length
? `<p class="transcript-roles">${speakerOrder.map((id, i) => {
const role = speakerRoles[id] || id;
@@ -1669,8 +1834,17 @@ function renderTranscriptResults(data) {
}).join('')}</p>`
: '';
// Segments panel — includes inline speaker legend at the top
const legendHtml = speakerOrder.length
? `<div class="segment-legend">${speakerOrder.map((id, i) => {
const role = speakerRoles[id] || id;
return `<span class="speaker-tag speaker-tag--${i % 6}">${escapeHtml(role)}<small>${escapeHtml(id)}</small></span>`;
}).join('')}</div>`
: '';
const segmentsHtml = hasSpeakers
? `<details class="segment-details"><summary class="segment-summary">Segments (${segments.length})</summary>
${legendHtml}
<div class="segment-list">${segments.map((seg) => {
const idx = speakerOrder.indexOf(seg.speaker);
const roleLabel = seg.speaker && speakerRoles[seg.speaker]
@@ -1684,36 +1858,83 @@ function renderTranscriptResults(data) {
}).join('')}</div></details>`
: '';
// SRT/VTT downloads (only if segments available)
const dlSrtVtt = segments.length
? `<button type="button" class="export-csv-btn" id="dlSrt">Download SRT</button>
<button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>`
? `<div class="transcript-downloads">
<button type="button" class="export-csv-btn" id="dlSrt">Download SRT</button>
<button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>
</div>`
: '';
// Meta stats row
const durStr = data.duration_sec ? `${fmtDuration(data.duration_sec)}` : null;
const langStr = data.language ? `🌐 ${data.language.toUpperCase()}` : null;
const spkStr = data.num_speakers > 1 ? `🗣 ${data.num_speakers} speakers` : null;
const metaParts = [durStr, langStr, spkStr].filter(Boolean);
const metaRow = metaParts.length
? `<div class="transcript-meta-row">${metaParts.map((p) => `<span>${p}</span>`).join('')}</div>`
: '';
// AI cleanup badge inline with engine label
const cleanupBadge = data.cleaned_by
? ` <span class="cleanup-badge">✓ Cleaned by ${escapeHtml(data.cleaned_by)}</span>`
: '';
const engineLine = data.model
? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong>${cleanupBadge}</p>`
: '';
// Action row above transcript (copy + TXT download)
const actionRow = `
<div class="transcript-actions">
<button type="button" class="export-csv-btn" id="txCopy">Copy</button>
<button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
</div>`;
lastRunEngine = data.engine || null;
els.results.innerHTML = `
<section class="result-section">
<h3>Transcript</h3>
${data.model ? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong></p>` : ''}
${engineLine}
${metaRow}
${rolesHtml}
${actionRow}
<div class="transcript-box"><pre class="transcript-text">${escapeHtml(data.transcript)}</pre></div>
${segmentsHtml}
<div class="transcript-downloads">
<button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
${dlSrtVtt}
</div>
${dlSrtVtt}
</section>
${renderFeedbackWidget()}`;
setupFeedbackWidget('transcribe');
const traceMeta = [];
if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, detail: '', status: 'complete' });
if (data.duration_sec) traceMeta.push({ label: `Duration: ${fmtDuration(data.duration_sec)}`, detail: '', status: 'complete' });
if (data.language) traceMeta.push({ label: `Language: ${data.language}`, detail: '', status: 'complete' });
if (data.num_speakers > 1) traceMeta.push({ label: `Speakers detected: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
if (data.num_speakers > 1) traceMeta.push({ label: `Speakers: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
if (data.model) traceMeta.push({ label: data.model, detail: '', status: 'complete' });
if (data.cleaned_by) traceMeta.push({ label: `Cleaned by ${data.cleaned_by}`, detail: '', status: 'complete' });
renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]);
}
function fmtDuration(secs) {
const m = Math.floor(secs / 60);
const s = Math.round(secs % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
async function copyTranscriptText() {
if (!lastTranscriptData?.transcript) return;
const btn = document.getElementById('txCopy');
try {
await navigator.clipboard.writeText(lastTranscriptData.transcript);
if (btn) {
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 1800);
}
} catch {
// clipboard unavailable — silently ignore
}
}
function fmtTime(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
@@ -1859,6 +2080,16 @@ function setupTranscribeControls() {
function setupVocabPresets() {
if (!els.vocabPresets) return;
const vocabCountEl = document.getElementById('vocabCharCount');
function updateVocabCount() {
if (!vocabCountEl || !els.initPromptInput) return;
const n = els.initPromptInput.value.length;
vocabCountEl.textContent = n;
vocabCountEl.closest('small')?.classList.toggle('vocab-char-count--warn', n > 450);
}
els.initPromptInput?.addEventListener('input', updateVocabCount);
els.vocabPresets.addEventListener('click', (e) => {
const btn = e.target.closest('.vocab-btn');
if (!btn) return;
@@ -1868,6 +2099,7 @@ function setupVocabPresets() {
els.vocabPresets.querySelectorAll('.vocab-btn').forEach((b) => b.classList.remove('is-active'));
btn.classList.add('is-active');
if (preset !== 'custom') els.initPromptInput.focus();
updateVocabCount();
}
});
}