1438 lines
44 KiB
HTML
1438 lines
44 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Speech to Text</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1115;
|
|
--panel: #171a21;
|
|
--panel-2: #1e222b;
|
|
--border: #2a2f3a;
|
|
--text: #e6e9ef;
|
|
--muted: #9aa3b2;
|
|
--accent: #6ee7b7;
|
|
--accent-dim: #2dd4bf;
|
|
--danger: #f87171;
|
|
--radius: 12px;
|
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
|
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; height: 100%; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--sans);
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
line-height: 1.5;
|
|
}
|
|
header {
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
}
|
|
header h1 { font-size: 18px; margin: 0; font-weight: 600; letter-spacing: -0.01em; }
|
|
header .tag { color: var(--muted); font-size: 13px; font-family: var(--mono); }
|
|
header .gear { color: var(--muted); font-size: 16px; cursor: pointer; margin-left: auto; }
|
|
header .gear:hover { color: var(--text); }
|
|
main {
|
|
flex: 1;
|
|
width: 100%;
|
|
max-width: 920px;
|
|
margin: 0 auto;
|
|
padding: 28px 24px 48px;
|
|
}
|
|
.controls {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
.controls label { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
|
|
select, .controls input[type=text] {
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 6px 10px;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
}
|
|
input[type=file] { display: none; }
|
|
|
|
/* --- Unified input zone --- */
|
|
.source-zone {
|
|
border: 2px dashed var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--panel);
|
|
padding: 36px 24px 24px;
|
|
text-align: center;
|
|
transition: border-color .15s ease, background .15s ease;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
position: relative;
|
|
}
|
|
.source-zone.drag { border-color: var(--accent-dim); background: var(--panel-2); }
|
|
.source-zone.recording { border-color: var(--danger); border-style: solid; cursor: default; }
|
|
.source-zone .big { font-size: 16px; font-weight: 500; color: var(--text); }
|
|
.source-zone .formats { color: var(--muted); font-size: 12px; margin-top: 14px; font-family: var(--mono); }
|
|
.source-actions {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 24px;
|
|
margin-top: 16px;
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
}
|
|
.source-actions a {
|
|
color: var(--accent-dim);
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.source-actions a:hover { color: var(--accent); }
|
|
|
|
/* recording state */
|
|
.rec-indicator {
|
|
display: none;
|
|
}
|
|
.source-zone.recording .source-idle { display: none; }
|
|
.source-zone.recording .rec-indicator { display: block; }
|
|
.rec-dot {
|
|
display: inline-block;
|
|
width: 12px; height: 12px;
|
|
background: var(--danger);
|
|
border-radius: 50%;
|
|
margin-right: 8px;
|
|
animation: pulse-dot 1.5s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse-dot { 0%,100%{ opacity: 1; } 50%{ opacity: .4; } }
|
|
.rec-time {
|
|
font-family: var(--mono);
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
margin: 12px 0;
|
|
}
|
|
.rec-stop {
|
|
margin-top: 16px;
|
|
background: var(--danger);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 8px 28px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.rec-stop:hover { opacity: .85; }
|
|
.storage-info {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
font-family: var(--mono);
|
|
margin-top: 8px;
|
|
}
|
|
.storage-info.warning { color: var(--danger); }
|
|
.job audio {
|
|
width: 100%;
|
|
height: 32px;
|
|
margin-top: 8px;
|
|
border-radius: 8px;
|
|
background: var(--panel-2);
|
|
display: none;
|
|
}
|
|
.job audio::-webkit-media-controls-panel {
|
|
background: var(--panel-2);
|
|
}
|
|
.job audio::-webkit-media-controls-current-time-display,
|
|
.job audio::-webkit-media-controls-time-remaining-display {
|
|
color: var(--text);
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* path row */
|
|
.path-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height .2s ease, margin-top .2s ease, opacity .2s ease;
|
|
opacity: 0;
|
|
}
|
|
.path-row.open {
|
|
max-height: 60px;
|
|
opacity: 1;
|
|
margin-top: 12px;
|
|
}
|
|
.path-row input[type=text] {
|
|
flex: 1;
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 8px 12px;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
}
|
|
.path-row input[type=text]::placeholder { color: var(--muted); opacity: .6; }
|
|
.path-row button { white-space: nowrap; }
|
|
|
|
.search-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 20px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.search-row input[type=text] {
|
|
flex: 1;
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 8px 12px;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
}
|
|
.search-row input[type=text]::placeholder { color: var(--muted); opacity: .6; }
|
|
.search-row label {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
white-space: nowrap;
|
|
}
|
|
.queue { display: flex; flex-direction: column; gap: 14px; }
|
|
.sentinel { min-height: 1px; }
|
|
.job {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
}
|
|
.job .row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 16px;
|
|
}
|
|
.job .name { font-family: var(--mono); font-size: 13px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.job .timestamp { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
|
.job .status { font-size: 12px; font-family: var(--mono); padding: 2px 8px; border-radius: 999px; }
|
|
.status.pending { color: var(--muted); background: var(--panel-2); }
|
|
.status.working { color: #0f1115; background: var(--accent); }
|
|
.status.done { color: #0f1115; background: var(--accent-dim); }
|
|
.status.error { color: #0f1115; background: var(--danger); }
|
|
.bar { height: 3px; background: var(--panel-2); overflow: hidden; }
|
|
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .2s ease; }
|
|
.bar.indeterminate > i {
|
|
width: 35%;
|
|
animation: slide 1.1s ease-in-out infinite;
|
|
}
|
|
@keyframes slide { 0%{margin-left:-35%} 100%{margin-left:100%} }
|
|
.progress-info {
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
padding: 2px 16px 8px;
|
|
}
|
|
.result { padding: 0 16px 16px; }
|
|
.result textarea {
|
|
width: 100%;
|
|
min-height: 120px;
|
|
resize: vertical;
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
.result .actions { display: flex; gap: 8px; margin-top: 10px; }
|
|
button {
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 6px 14px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
}
|
|
button:hover { border-color: var(--accent-dim); }
|
|
button.primary { background: var(--accent); color: #0f1115; border-color: var(--accent); font-weight: 600; }
|
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
|
.err-msg { color: var(--danger); font-size: 13px; font-family: var(--mono); padding: 0 16px 14px; }
|
|
.note-area { padding: 0 16px 8px; }
|
|
.note-area label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px; }
|
|
.note-area textarea {
|
|
width: 100%;
|
|
min-height: 48px;
|
|
max-height: 120px;
|
|
resize: vertical;
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 8px 12px;
|
|
font-family: var(--sans);
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
.summary-box {
|
|
margin-top: 10px;
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px 14px;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
min-height: 40px;
|
|
}
|
|
.summary-box h2, .summary-box h3 { margin: 0.4em 0; font-size: 14px; }
|
|
.summary-box ul, .summary-box ol { margin: 0.2em 0; padding-left: 1.4em; }
|
|
.summary-box li { margin: 0.15em 0; }
|
|
.summary-spinner { color: var(--muted); font-family: var(--mono); font-size: 13px; padding: 12px 0; }
|
|
.summary-error { color: var(--danger); font-family: var(--mono); font-size: 13px; }
|
|
footer { color: var(--muted); font-size: 12px; text-align: center; padding: 18px; border-top: 1px solid var(--border); }
|
|
footer code { font-family: var(--mono); }
|
|
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,.6);
|
|
z-index: 100;
|
|
}
|
|
.modal-overlay.open { display: flex; align-items: center; justify-content: center; }
|
|
.modal {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 24px;
|
|
width: 420px;
|
|
max-width: 90vw;
|
|
}
|
|
.modal h2 { margin: 0 0 16px; font-size: 16px; }
|
|
.modal label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; margin-top: 12px; }
|
|
.modal input[type=text] {
|
|
width: 100%;
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
}
|
|
.modal .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Speech to Text</h1>
|
|
<span class="tag">__MODEL_TAG__</span>
|
|
<span class="gear" id="settingsBtn" title="Settings">⚙</span>
|
|
</header>
|
|
<main>
|
|
<div class="controls">
|
|
<label>Language
|
|
<select id="lang">
|
|
<option value="fr">fr</option>
|
|
<option value="en">en</option>
|
|
<option value="es">es</option>
|
|
<option value="de">de</option>
|
|
<option value="it">it</option>
|
|
<option value="pt">pt</option>
|
|
<option value="nl">nl</option>
|
|
<option value="pl">pl</option>
|
|
<option value="ru">ru</option>
|
|
</select>
|
|
</label>
|
|
<label>Model
|
|
<select id="model"></select>
|
|
</label>
|
|
<label>Format
|
|
<select id="fmt">
|
|
<option value="json" selected>json</option>
|
|
<option value="text">text</option>
|
|
<option value="srt">srt</option>
|
|
<option value="vtt">vtt</option>
|
|
<option value="verbose_json">verbose_json</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="source-zone" id="sourceZone" tabindex="0">
|
|
<div class="source-idle">
|
|
<div class="big">Drop audio here</div>
|
|
<div class="source-actions">
|
|
<a id="chooseLink">choose file</a>
|
|
<a id="recordLink">record</a>
|
|
<a id="pathLink">paste path</a>
|
|
</div>
|
|
<div class="formats">m4a · ogg · mp3 · wav · flac · aac · opus · webm</div>
|
|
<div class="storage-info" id="storageInfo"></div>
|
|
</div>
|
|
<div class="rec-indicator">
|
|
<div style="display:flex;align-items:center;justify-content:center;gap:6px">
|
|
<span class="rec-dot"></span> Recording
|
|
</div>
|
|
<div class="rec-time" id="recTimer">0:00</div>
|
|
<button class="rec-stop" id="recStop">Stop</button>
|
|
</div>
|
|
</div>
|
|
<input type="file" id="file" multiple
|
|
accept=".m4a,.ogg,.mp3,.wav,.flac,.aac,.opus,.webm,audio/*">
|
|
<div class="path-row" id="pathRow">
|
|
<input type="text" id="filepath" placeholder="/path/to/recording.m4a" spellcheck="false">
|
|
<button id="pathBtn" class="primary">Transcribe</button>
|
|
</div>
|
|
|
|
<div class="search-row" id="searchRow">
|
|
<input type="text" id="searchInput" placeholder="Search transcripts…">
|
|
<label><input type="checkbox" id="searchAll" checked> all text</label>
|
|
</div>
|
|
<div class="queue" id="queue"></div>
|
|
<div class="sentinel" id="sentinel"></div>
|
|
</main>
|
|
<footer>
|
|
Backend: <code id="backend">checking...</code> · Max upload: <code>__MAX_MB__ MB</code>
|
|
</footer>
|
|
|
|
<div class="modal-overlay" id="settingsModal">
|
|
<div class="modal">
|
|
<h2>Settings</h2>
|
|
<label>LLM Endpoint <span style="font-size:11px;color:var(--muted)">(OpenAI-compatible, e.g. https://api.openai.com/v1)</span></label>
|
|
<input type="text" id="settingsUrl" placeholder="https://api.openai.com/v1">
|
|
<label>API Key</label>
|
|
<input type="text" id="settingsApiKey" placeholder="sk-...">
|
|
<label>Model</label>
|
|
<input type="text" id="settingsModel" placeholder="gpt-4o-mini">
|
|
<div class="actions">
|
|
<button id="settingsCancel">Cancel</button>
|
|
<button id="settingsSave" class="primary">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const STORAGE_KEY = 'stt_jobs';
|
|
const MAX_BYTES = __MAX_BYTES__;
|
|
const BATCH = 20;
|
|
const sourceZone = document.getElementById('sourceZone');
|
|
const fileInput = document.getElementById('file');
|
|
const queue = document.getElementById('queue');
|
|
const sentinel = document.getElementById('sentinel');
|
|
const langSel = document.getElementById('lang');
|
|
const fmtSel = document.getElementById('fmt');
|
|
const modelSel = document.getElementById('model');
|
|
const pathRow = document.getElementById('pathRow');
|
|
const pathInput = document.getElementById('filepath');
|
|
const pathBtn = document.getElementById('pathBtn');
|
|
|
|
langSel.value = "__DEFAULT_LANG__";
|
|
|
|
function debounce(fn, ms) {
|
|
let t;
|
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
|
}
|
|
|
|
// --- IndexedDB Audio Storage ---
|
|
const DB_NAME = 'stt_audio';
|
|
const DB_VERSION = 1;
|
|
const STORE_NAME = 'blobs';
|
|
let db = null;
|
|
|
|
function openAudioDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
req.onerror = () => reject(req.error);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onupgradeneeded = (e) => {
|
|
const db = e.target.result;
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
async function withDB() {
|
|
if (!db) db = await openAudioDB();
|
|
return db;
|
|
}
|
|
|
|
async function saveBlobToDB(id, blob) {
|
|
const database = await withDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = database.transaction(STORE_NAME, 'readwrite');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const req = store.put({ id, blob, ts: Date.now() });
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function getBlobFromDB(id) {
|
|
const database = await withDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = database.transaction(STORE_NAME, 'readonly');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const req = store.get(id);
|
|
req.onsuccess = () => resolve(req.result?.blob || null);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function deleteBlobFromDB(id) {
|
|
const database = await withDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = database.transaction(STORE_NAME, 'readwrite');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const req = store.delete(id);
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function getAllBlobIds() {
|
|
const database = await withDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = database.transaction(STORE_NAME, 'readonly');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const req = store.getAllKeys();
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function cleanupOrphanedBlobs() {
|
|
try {
|
|
const storedIds = await getAllBlobIds();
|
|
const jobIds = new Set(allData.map(j => j.id));
|
|
for (const id of storedIds) {
|
|
if (!jobIds.has(String(id))) {
|
|
await deleteBlobFromDB(id);
|
|
}
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// --- Storage Estimate ---
|
|
const FALLBACK_BPS = 128 * 1024 / 8; // 128 kbps = 16 KB/s
|
|
|
|
function getBitrateEstimate() {
|
|
const saved = localStorage.getItem('stt_observed_bps');
|
|
if (saved) return parseFloat(saved);
|
|
return FALLBACK_BPS;
|
|
}
|
|
|
|
function recordObservedBitrate(bytes, seconds) {
|
|
if (seconds < 3) return;
|
|
const bps = bytes / seconds;
|
|
const saved = localStorage.getItem('stt_observed_bps');
|
|
const old = saved ? parseFloat(saved) : bps;
|
|
const newBps = old * 0.7 + bps * 0.3;
|
|
localStorage.setItem('stt_observed_bps', String(Math.round(newBps)));
|
|
}
|
|
|
|
async function updateStorageDisplay() {
|
|
const el = document.getElementById('storageInfo');
|
|
if (!el) return;
|
|
try {
|
|
if (!navigator.storage || !navigator.storage.estimate) {
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
const estimate = await navigator.storage.estimate();
|
|
const used = estimate.usage || 0;
|
|
const quota = estimate.quota || 0;
|
|
if (!quota) {
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
const remaining = Math.max(0, quota - used);
|
|
const bps = getBitrateEstimate();
|
|
const mins = Math.floor(remaining / (bps * 60));
|
|
if (mins < 5) {
|
|
el.classList.add('warning');
|
|
el.textContent = `~${mins} min storage left`;
|
|
} else {
|
|
el.classList.remove('warning');
|
|
el.textContent = `~${mins} min storage left`;
|
|
}
|
|
} catch (e) {
|
|
el.textContent = '';
|
|
}
|
|
}
|
|
|
|
// --- Data layer ---
|
|
let allData = loadJobs();
|
|
const liveEls = new Set();
|
|
const renderedIds = new Set();
|
|
let displayData = null;
|
|
|
|
function loadJobs() {
|
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
|
|
}
|
|
|
|
function saveJobs() {
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(allData)); } catch {}
|
|
}
|
|
|
|
function syncToData(el) {
|
|
const entry = {
|
|
id: el._id,
|
|
name: el.querySelector('.name').textContent,
|
|
text: el._text || '',
|
|
result: el._result || null,
|
|
summary: el._summary || '',
|
|
note: el._note || '',
|
|
ts: el._ts || Date.now(),
|
|
hasBlob: el._hasBlob || false,
|
|
language: el._language || '',
|
|
source: el._source || '',
|
|
path: el._path || '',
|
|
};
|
|
const idx = allData.findIndex(j => j.id === entry.id);
|
|
if (idx >= 0) allData[idx] = entry;
|
|
else allData.unshift(entry);
|
|
}
|
|
|
|
function getDisplayData() {
|
|
return displayData || allData;
|
|
}
|
|
|
|
window.addEventListener('beforeunload', saveJobs);
|
|
|
|
const debouncedSave = debounce(saveJobs, 1000);
|
|
|
|
// --- Search ---
|
|
const searchInput = document.getElementById('searchInput');
|
|
const searchAll = document.getElementById('searchAll');
|
|
|
|
function fuzzyMatch(query, text) {
|
|
const q = query.toLowerCase();
|
|
const t = text.toLowerCase();
|
|
let qi = 0;
|
|
for (let i = 0; i < t.length && qi < q.length; i++) {
|
|
if (t[i] === q[qi]) qi++;
|
|
}
|
|
return qi === q.length;
|
|
}
|
|
|
|
function doSearch() {
|
|
const q = searchInput.value.trim();
|
|
if (!q) {
|
|
displayData = null;
|
|
} else {
|
|
const all = searchAll.checked;
|
|
displayData = allData.filter(job => {
|
|
const haystack = all
|
|
? ((job.text || '') + ' ' + (job.summary || '') + ' ' + (job.note || '') + ' ' + (job.name || ''))
|
|
: (job.summary || '');
|
|
return fuzzyMatch(q, haystack);
|
|
});
|
|
}
|
|
clearAndRender();
|
|
}
|
|
|
|
const debouncedSearch = debounce(doSearch, 500);
|
|
|
|
// --- Rendering ---
|
|
function clearAndRender() {
|
|
for (const el of [...queue.querySelectorAll('.job')]) {
|
|
if (!liveEls.has(el._id)) {
|
|
renderedIds.delete(el._id);
|
|
teardownAudioPlayer(el);
|
|
el.remove();
|
|
}
|
|
}
|
|
renderBatch();
|
|
}
|
|
|
|
function createRestoredEl(job) {
|
|
const el = makeJobEl(job.name, job.id);
|
|
el._text = job.text || '';
|
|
el._result = job.result;
|
|
el._summary = job.summary || '';
|
|
el._note = job.note || '';
|
|
el._ts = job.ts || Date.now();
|
|
el._hasBlob = job.hasBlob || false;
|
|
el._language = job.language || '';
|
|
el._source = job.source || '';
|
|
el._path = job.path || '';
|
|
setStatus(el, 'done', 'done');
|
|
el.querySelector('.bar').style.display = 'none';
|
|
const r = el.querySelector('.result');
|
|
r.style.display = 'block';
|
|
const ta = r.querySelector('textarea');
|
|
ta.value = job.text || '';
|
|
ta.style.height = Math.min(400, Math.max(120, (job.text || '').split('\n').length * 22 + 40)) + 'px';
|
|
const noteTa = el.querySelector('.note-area textarea');
|
|
if (noteTa && job.note) noteTa.value = job.note;
|
|
if (job.summary) {
|
|
const sa = el.querySelector('.summary-area');
|
|
sa.style.display = 'block';
|
|
sa.innerHTML = '<div class="summary-box">' + renderMarkdown(job.summary) + '</div>';
|
|
}
|
|
const ts = el.querySelector('.timestamp');
|
|
if (ts) ts.textContent = fmtTime(job.ts);
|
|
finishButtons(el);
|
|
setupAudioPlayer(el);
|
|
return el;
|
|
}
|
|
|
|
function renderBatch() {
|
|
const data = getDisplayData();
|
|
let count = 0;
|
|
for (const job of data) {
|
|
if (count >= BATCH) break;
|
|
if (renderedIds.has(job.id) || liveEls.has(job.id)) continue;
|
|
const el = createRestoredEl(job);
|
|
queue.appendChild(el);
|
|
renderedIds.add(job.id);
|
|
count++;
|
|
}
|
|
const remaining = data.filter(j => !renderedIds.has(j.id) && !liveEls.has(j.id)).length;
|
|
sentinel.style.display = remaining === 0 ? 'none' : '';
|
|
}
|
|
|
|
const sentinelObserver = new IntersectionObserver(entries => {
|
|
if (entries[0].isIntersecting) renderBatch();
|
|
}, { rootMargin: '400px' });
|
|
sentinelObserver.observe(sentinel);
|
|
|
|
searchInput.addEventListener('input', debouncedSearch);
|
|
searchAll.addEventListener('change', debouncedSearch);
|
|
|
|
// --- Settings ---
|
|
const settingsModal = document.getElementById('settingsModal');
|
|
const settingsUrl = document.getElementById('settingsUrl');
|
|
const settingsApiKey = document.getElementById('settingsApiKey');
|
|
const settingsModel = document.getElementById('settingsModel');
|
|
|
|
function loadSettings() {
|
|
settingsUrl.value = localStorage.getItem('stt_llm_url') || '';
|
|
settingsApiKey.value = localStorage.getItem('stt_llm_api_key') || '';
|
|
settingsModel.value = localStorage.getItem('stt_llm_model') || '';
|
|
if (!settingsUrl.value) {
|
|
fetch('/api/config').then(r => r.json()).then(c => {
|
|
if (!settingsUrl.value) settingsUrl.value = c.llm_url || '';
|
|
if (!settingsModel.value) settingsModel.value = c.llm_model || '';
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
document.getElementById('settingsBtn').addEventListener('click', () => {
|
|
loadSettings();
|
|
settingsModal.classList.add('open');
|
|
});
|
|
document.getElementById('settingsCancel').addEventListener('click', () => {
|
|
settingsModal.classList.remove('open');
|
|
});
|
|
settingsModal.addEventListener('click', e => { if (e.target === settingsModal) settingsModal.classList.remove('open'); });
|
|
document.getElementById('settingsSave').addEventListener('click', () => {
|
|
localStorage.setItem('stt_llm_url', settingsUrl.value.trim());
|
|
localStorage.setItem('stt_llm_api_key', settingsApiKey.value.trim());
|
|
localStorage.setItem('stt_llm_model', settingsModel.value.trim());
|
|
settingsModal.classList.remove('open');
|
|
});
|
|
|
|
function getLLMConfig() {
|
|
return {
|
|
url: localStorage.getItem('stt_llm_url') || '',
|
|
api_key: localStorage.getItem('stt_llm_api_key') || '',
|
|
model: localStorage.getItem('stt_llm_model') || '',
|
|
};
|
|
}
|
|
|
|
// --- Models & Health ---
|
|
fetch('/api/models').then(r => r.json()).then(j => {
|
|
const sel = modelSel;
|
|
sel.innerHTML = '';
|
|
for (const m of j.models) {
|
|
const opt = document.createElement('option');
|
|
opt.value = m;
|
|
opt.textContent = m + (m === j.default ? ' (default)' : '');
|
|
sel.appendChild(opt);
|
|
}
|
|
if (j.default) sel.value = j.default;
|
|
}).catch(() => {});
|
|
|
|
fetch('/api/health').then(r => r.json()).then(j => {
|
|
document.getElementById('backend').textContent =
|
|
j.backend_ok ? (j.backend_url + ' \u00b7 ok') : (j.backend_url + ' \u00b7 DOWN');
|
|
document.getElementById('backend').style.color = j.backend_ok ? '' : 'var(--danger)';
|
|
}).catch(() => {
|
|
document.getElementById('backend').textContent = 'unreachable';
|
|
});
|
|
|
|
// --- Source zone interactions ---
|
|
sourceZone.addEventListener('dragenter', e => { e.preventDefault(); sourceZone.classList.add('drag'); });
|
|
sourceZone.addEventListener('dragover', e => { e.preventDefault(); sourceZone.classList.add('drag'); });
|
|
sourceZone.addEventListener('dragleave', e => { e.preventDefault(); sourceZone.classList.remove('drag'); });
|
|
sourceZone.addEventListener('drop', e => { e.preventDefault(); sourceZone.classList.remove('drag'); handleFiles(e.dataTransfer.files); });
|
|
sourceZone.addEventListener('click', e => {
|
|
if (e.target.closest('#recordLink') || e.target.closest('#pathLink') || e.target.closest('#recStop')) return;
|
|
fileInput.click();
|
|
});
|
|
sourceZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
|
|
|
|
document.getElementById('chooseLink').addEventListener('click', e => { e.stopPropagation(); fileInput.click(); });
|
|
document.getElementById('recordLink').addEventListener('click', e => { e.stopPropagation(); startRecording(); });
|
|
document.getElementById('pathLink').addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
pathRow.classList.toggle('open');
|
|
if (pathRow.classList.contains('open')) pathInput.focus();
|
|
});
|
|
document.getElementById('recStop').addEventListener('click', e => { e.stopPropagation(); stopRecording(); });
|
|
|
|
fileInput.addEventListener('change', e => { handleFiles(e.target.files); fileInput.value = ''; });
|
|
pathBtn.addEventListener('click', () => {
|
|
const p = pathInput.value.trim();
|
|
if (!p) return;
|
|
transcribePath(p);
|
|
pathInput.value = '';
|
|
pathRow.classList.remove('open');
|
|
});
|
|
pathInput.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); pathBtn.click(); }
|
|
});
|
|
|
|
function handleFiles(files) {
|
|
for (const f of files) enqueue(f);
|
|
}
|
|
|
|
// --- Recording ---
|
|
let mediaRecorder = null;
|
|
let recChunks = [];
|
|
let recStart = null;
|
|
let recTimerInterval = null;
|
|
const recTimer = document.getElementById('recTimer');
|
|
|
|
async function startRecording() {
|
|
let stream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
} catch (e) {
|
|
alert('Microphone access denied: ' + e.message);
|
|
return;
|
|
}
|
|
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus'
|
|
: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm'
|
|
: 'audio/ogg';
|
|
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
|
recChunks = [];
|
|
recStart = Date.now();
|
|
|
|
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data); };
|
|
|
|
mediaRecorder.onstop = () => {
|
|
stream.getTracks().forEach(t => t.stop());
|
|
clearInterval(recTimerInterval);
|
|
sourceZone.classList.remove('recording');
|
|
mediaRecorder = null;
|
|
|
|
if (recChunks.length === 0) return;
|
|
const ext = mimeType.startsWith('audio/webm') ? 'webm' : 'ogg';
|
|
const blob = new Blob(recChunks, { type: mimeType });
|
|
const now = new Date();
|
|
const ts = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0') + '_' + String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0');
|
|
const name = 'recording_' + ts + '.' + ext;
|
|
const el = makeJobEl(name);
|
|
liveEls.add(el._id);
|
|
renderedIds.add(el._id);
|
|
queue.prepend(el);
|
|
|
|
el._source = 'record';
|
|
el._language = langSel.value;
|
|
el._autoSummarize = true;
|
|
syncToData(el);
|
|
saveJobs();
|
|
|
|
saveBlobToDB(el._id, blob).then(() => {
|
|
el._hasBlob = true;
|
|
syncToData(el);
|
|
saveJobs();
|
|
updateStorageDisplay();
|
|
setupAudioPlayer(el);
|
|
}).catch(err => {
|
|
console.warn('Failed to persist recording to IndexedDB:', err);
|
|
});
|
|
|
|
const recSeconds = (Date.now() - recStart) / 1000;
|
|
recordObservedBitrate(blob.size, recSeconds);
|
|
|
|
setStatus(el, 'working', 'uploading\u2026');
|
|
const bar = el.querySelector('.bar');
|
|
bar.classList.add('indeterminate');
|
|
|
|
const fd = new FormData();
|
|
fd.append('file', new File([blob], name, { type: mimeType }), name);
|
|
fd.append('language', langSel.value);
|
|
fd.append('response_format', fmtSel.value);
|
|
fd.append('model', modelSel.value);
|
|
|
|
streamRequest(el, '/api/transcribe', { method: 'POST', body: fd });
|
|
};
|
|
|
|
mediaRecorder.start(1000);
|
|
sourceZone.classList.add('recording');
|
|
recTimer.textContent = '0:00';
|
|
recTimerInterval = setInterval(() => {
|
|
const elapsed = Math.floor((Date.now() - recStart) / 1000);
|
|
const m = Math.floor(elapsed / 60);
|
|
const s = elapsed % 60;
|
|
recTimer.textContent = m + ':' + String(s).padStart(2, '0');
|
|
}, 500);
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
mediaRecorder.stop();
|
|
}
|
|
}
|
|
|
|
async function setupAudioPlayer(el) {
|
|
const audio = el.querySelector('audio');
|
|
if (!audio) return;
|
|
if (el._audioUrl) {
|
|
URL.revokeObjectURL(el._audioUrl);
|
|
el._audioUrl = null;
|
|
}
|
|
let blob;
|
|
try {
|
|
blob = await getBlobFromDB(el._id);
|
|
} catch (e) { return; }
|
|
if (!blob) return;
|
|
const url = URL.createObjectURL(blob);
|
|
el._audioUrl = url;
|
|
audio.src = url;
|
|
audio.style.display = 'block';
|
|
}
|
|
|
|
function teardownAudioPlayer(el) {
|
|
if (el._audioUrl) {
|
|
URL.revokeObjectURL(el._audioUrl);
|
|
el._audioUrl = null;
|
|
}
|
|
}
|
|
|
|
// --- Progress formatting ---
|
|
function fmtDur(s) {
|
|
s = Math.floor(s);
|
|
const h = Math.floor(s / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
const sec = s % 60;
|
|
if (h > 0) return h + 'h' + String(m).padStart(2, '0') + 'm' + String(sec).padStart(2, '0') + 's';
|
|
if (m > 0) return m + 'm' + String(sec).padStart(2, '0') + 's';
|
|
return sec + 's';
|
|
}
|
|
|
|
function fmtTime(ts) {
|
|
const d = new Date(ts);
|
|
const pad = n => String(n).padStart(2, '0');
|
|
return pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
}
|
|
|
|
// --- Job elements ---
|
|
function makeJobEl(name, id) {
|
|
const el = document.createElement('div');
|
|
el.className = 'job';
|
|
el._id = id || Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
el._ts = Date.now();
|
|
el.innerHTML = `
|
|
<div class="row">
|
|
<span class="name"></span>
|
|
<span class="timestamp"></span>
|
|
<span class="status pending">queued</span>
|
|
</div>
|
|
<div class="bar"><i></i></div>
|
|
<div class="progress-info" style="display:none"></div>
|
|
<audio controls></audio>
|
|
<div class="result" style="display:none">
|
|
<textarea readonly></textarea>
|
|
<div class="actions">
|
|
<button class="copy">Copy</button>
|
|
<button class="summarize">Summarize</button>
|
|
<button class="retranscribe">Retranscribe</button>
|
|
<button class="download primary">Download</button>
|
|
<button class="delete" style="margin-left:auto; color:var(--danger)">Delete</button>
|
|
</div>
|
|
<div class="summary-area" style="display:none"></div>
|
|
</div>
|
|
<div class="note-area">
|
|
<label>Notes</label>
|
|
<textarea placeholder="Add a note\u2026"></textarea>
|
|
</div>
|
|
<div class="err-msg" style="display:none"></div>`;
|
|
el.querySelector('.name').textContent = name;
|
|
el.querySelector('.timestamp').textContent = fmtTime(el._ts);
|
|
el.querySelector('.retranscribe').addEventListener('click', async () => {
|
|
if (el.querySelector('.status').classList.contains('working')) return;
|
|
await doRetranscribe(el, langSel.value);
|
|
});
|
|
const noteTa = el.querySelector('.note-area textarea');
|
|
noteTa.addEventListener('input', () => { el._note = noteTa.value; syncToData(el); debouncedSave(); });
|
|
el.querySelector('.delete').addEventListener('click', () => {
|
|
if (confirm('Delete this transcription?')) {
|
|
allData = allData.filter(j => j.id !== el._id);
|
|
liveEls.delete(el._id);
|
|
teardownAudioPlayer(el);
|
|
el.remove();
|
|
saveJobs();
|
|
if (el._hasBlob) {
|
|
deleteBlobFromDB(el._id).catch(() => {}).then(updateStorageDisplay);
|
|
}
|
|
}
|
|
});
|
|
return el;
|
|
}
|
|
|
|
function setStatus(el, cls, text) {
|
|
const s = el.querySelector('.status');
|
|
s.className = 'status ' + cls;
|
|
s.textContent = text;
|
|
}
|
|
|
|
function setProgress(el, evt) {
|
|
const bar = el.querySelector('.bar');
|
|
const fill = bar.querySelector('i');
|
|
const info = el.querySelector('.progress-info');
|
|
|
|
const pct = evt.duration > 0 ? Math.round((evt.elapsed / evt.duration) * 100) : 0;
|
|
|
|
if (evt.total > 1) {
|
|
setStatus(el, 'working', 'chunk ' + evt.chunk + '/' + evt.total);
|
|
} else {
|
|
setStatus(el, 'working', 'transcribing');
|
|
}
|
|
|
|
bar.classList.remove('indeterminate');
|
|
fill.style.width = pct + '%';
|
|
|
|
if (evt.total > 1) {
|
|
info.style.display = 'block';
|
|
info.textContent = 'chunk ' + evt.chunk + '/' + evt.total
|
|
+ ' \u00b7 ' + fmtDur(evt.elapsed) + '/' + fmtDur(evt.duration)
|
|
+ ' \u00b7 ' + pct + '%';
|
|
} else {
|
|
info.style.display = 'block';
|
|
info.textContent = fmtDur(evt.duration);
|
|
}
|
|
}
|
|
|
|
function fail(el, msg) {
|
|
setStatus(el, 'error', 'error');
|
|
el.querySelector('.bar').style.display = 'none';
|
|
const p = el.querySelector('.progress-info');
|
|
p.style.display = 'none';
|
|
const e = el.querySelector('.err-msg');
|
|
e.style.display = 'block';
|
|
e.textContent = msg;
|
|
if (el._text) {
|
|
const r = el.querySelector('.result');
|
|
r.style.display = 'block';
|
|
const ta = r.querySelector('textarea');
|
|
ta.value = el._text;
|
|
}
|
|
if (el._summary) {
|
|
const sa = el.querySelector('.summary-area');
|
|
sa.style.display = 'block';
|
|
sa.innerHTML = '<div class="summary-box">' + renderMarkdown(el._summary) + '</div>';
|
|
}
|
|
}
|
|
|
|
function finishButtons(el) {
|
|
const displayText = el.querySelector('.result textarea').value;
|
|
el.querySelector('.copy').onclick = () => {
|
|
navigator.clipboard.writeText(displayText);
|
|
};
|
|
el.querySelector('.download').onclick = () => {
|
|
if (!el._result) return;
|
|
const fmt = el._result.format;
|
|
const rawContent = el._result.content;
|
|
const ext = fmt === 'srt' ? 'srt' : fmt === 'vtt' ? 'vtt' : 'txt';
|
|
const base = el.querySelector('.name').textContent.replace(/\.[^.]+$/, '');
|
|
const blob = new Blob([rawContent], {type: 'text/plain'});
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = base + '.' + ext;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
};
|
|
el.querySelector('.summarize').onclick = () => doSummarize(el);
|
|
}
|
|
|
|
async function doRetranscribe(el, newLanguage) {
|
|
if (el.querySelector('.status').classList.contains('working')) return;
|
|
|
|
el._language = newLanguage;
|
|
el._segments = [];
|
|
|
|
const resultArea = el.querySelector('.result');
|
|
resultArea.style.display = 'none';
|
|
const summaryArea = el.querySelector('.summary-area');
|
|
summaryArea.style.display = 'none';
|
|
const bar = el.querySelector('.bar');
|
|
bar.style.display = '';
|
|
bar.classList.add('indeterminate');
|
|
const fill = bar.querySelector('i');
|
|
fill.style.width = '0%';
|
|
const info = el.querySelector('.progress-info');
|
|
info.style.display = 'none';
|
|
info.textContent = '';
|
|
setStatus(el, 'working', 'transcribing');
|
|
liveEls.add(el._id);
|
|
syncToData(el);
|
|
saveJobs();
|
|
|
|
// Try IndexedDB blob first (works for old jobs too if blob was saved)
|
|
let blob = null;
|
|
try { blob = await getBlobFromDB(el._id); } catch (e) {}
|
|
|
|
if (blob) {
|
|
const name = el.querySelector('.name').textContent;
|
|
const fd = new FormData();
|
|
fd.append('file', new File([blob], name, { type: blob.type || 'audio/webm' }), name);
|
|
fd.append('language', newLanguage);
|
|
fd.append('response_format', fmtSel.value);
|
|
fd.append('model', modelSel.value);
|
|
streamRequest(el, '/api/transcribe', { method: 'POST', body: fd });
|
|
return;
|
|
}
|
|
|
|
if (el._source === 'path' && el._path) {
|
|
streamRequest(el, '/api/transcribe/path', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
path: el._path,
|
|
language: newLanguage,
|
|
response_format: fmtSel.value,
|
|
model: modelSel.value
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
fail(el, 'Audio not saved locally. Jobs created before this feature cannot be re-transcribed.');
|
|
}
|
|
|
|
// --- Upload & transcribe ---
|
|
function enqueue(file) {
|
|
const el = makeJobEl(file.name);
|
|
liveEls.add(el._id);
|
|
renderedIds.add(el._id);
|
|
queue.prepend(el);
|
|
|
|
if (file.size > MAX_BYTES) {
|
|
fail(el, 'File is ' + (file.size/1048576).toFixed(1) + ' MB; limit is ' + (MAX_BYTES/1048576|0) + ' MB.');
|
|
return el;
|
|
}
|
|
|
|
el._source = 'upload';
|
|
el._language = langSel.value;
|
|
syncToData(el);
|
|
saveJobs();
|
|
|
|
saveBlobToDB(el._id, file).then(() => {
|
|
el._hasBlob = true;
|
|
syncToData(el);
|
|
saveJobs();
|
|
updateStorageDisplay();
|
|
setupAudioPlayer(el);
|
|
}).catch(err => {
|
|
console.warn('Failed to persist upload to IndexedDB:', err);
|
|
});
|
|
|
|
setStatus(el, 'working', 'uploading\u2026');
|
|
const bar = el.querySelector('.bar');
|
|
bar.classList.add('indeterminate');
|
|
|
|
const fd = new FormData();
|
|
fd.append('file', file, file.name);
|
|
fd.append('language', langSel.value);
|
|
fd.append('response_format', fmtSel.value);
|
|
fd.append('model', modelSel.value);
|
|
|
|
streamRequest(el, '/api/transcribe', { method: 'POST', body: fd });
|
|
return el;
|
|
}
|
|
|
|
function transcribePath(filepath) {
|
|
const basename = filepath.split('/').pop();
|
|
const el = makeJobEl(basename);
|
|
liveEls.add(el._id);
|
|
renderedIds.add(el._id);
|
|
queue.prepend(el);
|
|
|
|
el._source = 'path';
|
|
el._path = filepath;
|
|
el._language = langSel.value;
|
|
syncToData(el);
|
|
saveJobs();
|
|
|
|
setStatus(el, 'working', 'transcribing');
|
|
const bar = el.querySelector('.bar');
|
|
bar.classList.add('indeterminate');
|
|
|
|
streamRequest(el, '/api/transcribe/path', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
path: filepath,
|
|
language: langSel.value,
|
|
response_format: fmtSel.value,
|
|
model: modelSel.value
|
|
})
|
|
});
|
|
return el;
|
|
}
|
|
|
|
async function streamRequest(el, url, opts) {
|
|
el._segments = [];
|
|
el._result = null;
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(url, opts);
|
|
} catch (e) {
|
|
fail(el, 'Network error.');
|
|
liveEls.delete(el._id);
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let msg = 'Error ' + response.status;
|
|
try {
|
|
const data = await response.json();
|
|
if (data.error) msg = data.error;
|
|
} catch (_) {}
|
|
fail(el, msg);
|
|
liveEls.delete(el._id);
|
|
return;
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let doneSeen = false;
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop();
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const evt = JSON.parse(line);
|
|
if (evt.type === 'progress') {
|
|
setProgress(el, evt);
|
|
} else if (evt.type === 'segment') {
|
|
el._segments.push(evt);
|
|
showLiveText(el, el._segments);
|
|
} else if (evt.type === 'done') {
|
|
doneSeen = true;
|
|
el._result = { content: evt.content, format: evt.format };
|
|
finishStream(el);
|
|
} else if (evt.type === 'error') {
|
|
fail(el, evt.error || 'transcription failed');
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
if (buffer.trim()) {
|
|
try {
|
|
const evt = JSON.parse(buffer);
|
|
if (evt.type === 'done') {
|
|
doneSeen = true;
|
|
el._result = { content: evt.content, format: evt.format };
|
|
finishStream(el);
|
|
} else if (evt.type === 'error') {
|
|
fail(el, evt.error || 'transcription failed');
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} catch (e) {
|
|
if (!doneSeen) fail(el, 'Connection lost.');
|
|
}
|
|
|
|
if (!doneSeen && el.querySelector('.status').classList.contains('working')) {
|
|
fail(el, 'No result received.');
|
|
}
|
|
liveEls.delete(el._id);
|
|
}
|
|
|
|
function showLiveText(el, segments) {
|
|
const r = el.querySelector('.result');
|
|
r.style.display = 'block';
|
|
let ta = r.querySelector('textarea');
|
|
if (!ta) return null;
|
|
const texts = segments.map(s => s.text).filter(Boolean);
|
|
ta.value = texts.join(' ');
|
|
ta.style.height = Math.min(400, Math.max(120, texts.length * 22 + 40)) + 'px';
|
|
return ta;
|
|
}
|
|
|
|
function finishStream(el) {
|
|
setStatus(el, 'done', 'done');
|
|
const bar = el.querySelector('.bar');
|
|
bar.style.display = 'none';
|
|
const p = el.querySelector('.progress-info');
|
|
if (p) p.style.display = 'none';
|
|
el._text = el.querySelector('.result textarea').value;
|
|
finishButtons(el);
|
|
liveEls.delete(el._id);
|
|
syncToData(el);
|
|
saveJobs();
|
|
if (el._autoSummarize && getLLMConfig().url) {
|
|
doSummarize(el);
|
|
}
|
|
}
|
|
|
|
// --- Summarize ---
|
|
async function doSummarize(el) {
|
|
const cfg = getLLMConfig();
|
|
if (!cfg.url) {
|
|
alert('No LLM endpoint configured. Click the \u2699 gear icon to set one up.');
|
|
return;
|
|
}
|
|
|
|
const text = el._text || el.querySelector('.result textarea').value;
|
|
if (!text) return;
|
|
|
|
const area = el.querySelector('.summary-area');
|
|
area.style.display = 'block';
|
|
area.innerHTML = '<div class="summary-spinner">Summarizing\u2026</div>';
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch('/api/summarize', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: text, url: cfg.url, api_key: cfg.api_key, model: cfg.model })
|
|
});
|
|
} catch (e) {
|
|
area.innerHTML = '<div class="summary-error">Request failed: ' + e.message + '</div>';
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let msg = 'Error ' + response.status;
|
|
try { const d = await response.json(); if (d.error) msg = d.error; } catch (_) {}
|
|
area.innerHTML = '<div class="summary-error">' + msg + '</div>';
|
|
return;
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let summary = '';
|
|
let done = false;
|
|
|
|
try {
|
|
while (true) {
|
|
const { done: rd, value } = await reader.read();
|
|
if (rd) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop();
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const evt = JSON.parse(line);
|
|
if (evt.type === 'token') {
|
|
summary += evt.text;
|
|
area.innerHTML = '<div class="summary-box">' + renderMarkdown(summary) + '</div>';
|
|
} else if (evt.type === 'done') {
|
|
done = true;
|
|
} else if (evt.type === 'error') {
|
|
area.innerHTML = '<div class="summary-error">' + (evt.error || 'summarization failed') + '</div>';
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
if (buffer.trim()) {
|
|
try {
|
|
const evt = JSON.parse(buffer);
|
|
if (evt.type === 'error') {
|
|
area.innerHTML = '<div class="summary-error">' + (evt.error || 'summarization failed') + '</div>';
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} catch (e) {
|
|
if (!done) area.innerHTML = '<div class="summary-error">Connection lost.</div>';
|
|
return;
|
|
}
|
|
|
|
el._summary = summary;
|
|
syncToData(el);
|
|
saveJobs();
|
|
}
|
|
|
|
function renderMarkdown(md) {
|
|
let html = md
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
const lines = html.split('\n');
|
|
let out = '';
|
|
let inList = false;
|
|
let listType = '';
|
|
for (const line of lines) {
|
|
const olMatch = line.match(/^(\d+)\. (.+)$/);
|
|
const ulMatch = line.match(/^[-*] (.+)$/);
|
|
if (olMatch) {
|
|
if (!inList || listType !== 'ol') { if (inList) out += '</' + listType + '>'; out += '<ol>'; inList = true; listType = 'ol'; }
|
|
out += '<li>' + olMatch[2] + '</li>';
|
|
} else if (ulMatch) {
|
|
if (!inList || listType !== 'ul') { if (inList) out += '</' + listType + '>'; out += '<ul>'; inList = true; listType = 'ul'; }
|
|
out += '<li>' + ulMatch[1] + '</li>';
|
|
} else {
|
|
if (inList) { out += '</' + listType + '>'; inList = false; }
|
|
if (line.startsWith('<h')) {
|
|
out += line;
|
|
} else if (line.trim()) {
|
|
out += '<p>' + line + '</p>';
|
|
}
|
|
}
|
|
}
|
|
if (inList) out += '</' + listType + '>';
|
|
return out;
|
|
}
|
|
|
|
// --- Init ---
|
|
(async function init() {
|
|
await cleanupOrphanedBlobs();
|
|
updateStorageDisplay();
|
|
clearAndRender();
|
|
setInterval(updateStorageDisplay, 30000);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html> |