c77efa241c
- Default UI language to English; lang switcher (EN/NO/UK/PL) persisted in localStorage - Rename 'rettssak/tingrett' preset to 'Mediation / legal meeting' — court recording is illegal - Add Ukrainian (uk) and Polish (pl) as selectable audio transcription languages - TRANSCRIBE_I18N translation object drives all status messages, labels, and trace text - Apache ProxyTimeout raised to 1800s on server (was 300s — caused 504 on large files) - set_time_limit(0) + ignore_user_abort(true) in api/transcribe.php - applyTranscribeI18n() patches data-i18n / data-i18n-placeholder / data-i18n-aria attrs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1356 lines
52 KiB
JavaScript
1356 lines
52 KiB
JavaScript
const state = {
|
||
activeTool: 'ask',
|
||
authenticated: Boolean(window.DBN_TOOLS_AUTHENTICATED),
|
||
};
|
||
|
||
let lastTimelineEvents = [];
|
||
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
|
||
let lastTranscriptData = 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',
|
||
mediation: 'rettsmekling, meklingsnemnd, familievernkontor, mekling, forlik, meklingsprotokoll, foreldreplan, avtale, prosessfullmektig, advokat, dommer, vitne, sakkyndig, statsforvalter, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, tilsvar, stevning, kjennelse, anke',
|
||
generell: 'bokmål, nynorsk, statsforvalter, kommunen, forvaltning, klage, vedtak, rettigheter, plikter, protokoll, referat, rapport, dokumentasjon, velferd',
|
||
custom: '',
|
||
};
|
||
|
||
let uiLang = localStorage.getItem('dbn-ui-lang') || 'en';
|
||
|
||
const TRANSCRIBE_I18N = {
|
||
en: {
|
||
engine: 'Engine',
|
||
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
|
||
engineOpenaiLabel: 'OpenAI Whisper API',
|
||
engineAzureLabel: 'Azure AI Speech (nb-NO)',
|
||
apiKey: 'API Key',
|
||
apiKeyHint: 'Used for this request only, never stored. Max 25 MB.',
|
||
region: 'Region',
|
||
model: 'Model',
|
||
modelFastest: 'Fastest',
|
||
modelBalanced: 'Balanced',
|
||
modelBest: 'Best quality',
|
||
transcribeLang: 'Audio language',
|
||
autoDetectHint: '(may confuse nb/da/sv)',
|
||
speakers: 'Speakers',
|
||
identifySpeakers: 'Identify speakers',
|
||
speakersCount: 'Count',
|
||
speakersPlaceholder: 'auto',
|
||
speakersAriaLabel: 'Expected number of speakers',
|
||
vocabulary: 'Vocabulary',
|
||
vocabPresetChildWelfare: 'Child welfare / CPS',
|
||
vocabPresetMediation: 'Mediation / legal meeting',
|
||
vocabPresetGeneral: 'General Norwegian',
|
||
vocabPresetCustom: 'Custom',
|
||
vocabPlaceholder: 'Technical terms and names for Whisper to recognise, e.g. Barnevernet, mediation, family services…',
|
||
vocabHint: 'Helps Whisper recognise technical terms. Not included in the transcript.',
|
||
uploadAria: 'Audio upload',
|
||
uploadDrop: 'Drop audio file(s) here, or',
|
||
uploadBrowse: 'browse',
|
||
uploadHint: 'max 200 MB per file',
|
||
uploadAddFiles: '+ Add files',
|
||
uploadClearQueue: '× Clear queue',
|
||
expertSettings: 'Advanced settings',
|
||
task: 'Task',
|
||
taskTranscribe: 'Transcribe',
|
||
taskTranslate: 'Translate to English',
|
||
beamSize: 'Beam size',
|
||
beamFastest: '(fastest)',
|
||
beamBest: '(best)',
|
||
vadFilter: 'VAD filter',
|
||
vadFilterLabel: 'Remove silence',
|
||
run: 'Run',
|
||
running: 'Transcribing…',
|
||
runningOther: 'Running…',
|
||
readyTitle: 'Ready',
|
||
readyDesc: 'Select a tool, run a request, and the result appears here.',
|
||
noFileSelected: 'Select at least one audio file before transcribing.',
|
||
missingOpenaiKey: 'Enter a valid OpenAI API key (sk-…) before running.',
|
||
openaiFileTooLarge: (f) => `OpenAI Whisper has a 25 MB limit. Use the GPU engine for ${f}.`,
|
||
missingAzureKey: 'Enter an Azure Speech API key before running.',
|
||
clipLabel: (i, total) => total > 1 ? `Clip ${i}/${total}` : 'Transcribing',
|
||
transcribeFailed: (s) => `Transcription failed (HTTP ${s}).`,
|
||
errorLabel: (clip) => `Error – ${clip}`,
|
||
filesSkipped: (names) => `Skipped: ${names}`,
|
||
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — max 200 MB)`,
|
||
filesInQueue: (n) => `${n} file${n !== 1 ? 's' : ''} in queue.`,
|
||
done: (n, dur) => n > 1 ? `Done · ${n} clips · Total audio: ${dur}` : `Done · Audio: ${dur}`,
|
||
traceUploadLabel: (clip, eng) => `${clip} — uploading to ${eng}`,
|
||
traceUploadDetail: (eng) => eng === 'gpu' ? 'Sending audio to cuttlefish GPU…' : `Sending audio to ${eng}…`,
|
||
traceProcessingLabel: (clip, eng) => `${clip} — ${eng} transcribing`,
|
||
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transcribing. Large files take 1–3 minutes.' : `${eng} processing audio.`,
|
||
traceStillLabel: (clip) => `${clip} — still processing…`,
|
||
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m}m ${s}s elapsed — working through the audio.` : `${e}s elapsed — processing.`; },
|
||
},
|
||
no: {
|
||
engine: 'Motor',
|
||
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
|
||
engineOpenaiLabel: 'OpenAI Whisper API',
|
||
engineAzureLabel: 'Azure AI Speech (nb-NO)',
|
||
apiKey: 'API-nøkkel',
|
||
apiKeyHint: 'Brukes kun for denne forespørselen, lagres aldri. Maks 25 MB.',
|
||
region: 'Region',
|
||
model: 'Modell',
|
||
modelFastest: 'Raskest',
|
||
modelBalanced: 'Balansert',
|
||
modelBest: 'Beste kvalitet',
|
||
transcribeLang: 'Språk i lydfil',
|
||
autoDetectHint: '(kan forveksle nb/da/sv)',
|
||
speakers: 'Talere',
|
||
identifySpeakers: 'Skill ut talere',
|
||
speakersCount: 'Antall',
|
||
speakersPlaceholder: 'auto',
|
||
speakersAriaLabel: 'Forventet antall talere',
|
||
vocabulary: 'Ordliste',
|
||
vocabPresetChildWelfare: 'Barnerett / CPS',
|
||
vocabPresetMediation: 'Mekling / møter',
|
||
vocabPresetGeneral: 'Generell norsk',
|
||
vocabPresetCustom: 'Egendefinert',
|
||
vocabPlaceholder: 'Fagord og navn Whisper skal gjenkjenne, f.eks. Barnevernet, Fylkesnemnda, mekling…',
|
||
vocabHint: 'Hjelper Whisper gjenkjenne fagtermer. Ikke inkludert i utskriften.',
|
||
uploadAria: 'Lydopplasting',
|
||
uploadDrop: 'Slipp lydfil(er) her, eller',
|
||
uploadBrowse: 'bla',
|
||
uploadHint: 'maks 200 MB per fil',
|
||
uploadAddFiles: '+ Legg til filer',
|
||
uploadClearQueue: '× Tøm kø',
|
||
expertSettings: 'Ekspertinnstillinger',
|
||
task: 'Oppgave',
|
||
taskTranscribe: 'Transkriber',
|
||
taskTranslate: 'Oversett til engelsk',
|
||
beamSize: 'Beam size',
|
||
beamFastest: '(raskest)',
|
||
beamBest: '(best)',
|
||
vadFilter: 'VAD-filter',
|
||
vadFilterLabel: 'Fjern stillhet',
|
||
run: 'Kjør',
|
||
running: 'Transkriberer…',
|
||
runningOther: 'Kjører…',
|
||
readyTitle: 'Klar',
|
||
readyDesc: 'Velg et verktøy, kjør en forespørsel, og svaret vises her.',
|
||
noFileSelected: 'Velg minst én lydfil før transkripsjon.',
|
||
missingOpenaiKey: 'Legg inn en gyldig OpenAI API-nøkkel (sk-…) før du kjører.',
|
||
openaiFileTooLarge: (f) => `OpenAI Whisper har 25 MB-grense. Bruk GPU-motor for ${f}.`,
|
||
missingAzureKey: 'Legg inn Azure Speech API-nøkkel før du kjører.',
|
||
clipLabel: (i, total) => total > 1 ? `Klipp ${i}/${total}` : 'Transkriberer',
|
||
transcribeFailed: (s) => `Transkripsjon feilet (HTTP ${s}).`,
|
||
errorLabel: (clip) => `Feil – ${clip}`,
|
||
filesSkipped: (names) => `Hoppet over: ${names}`,
|
||
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`,
|
||
filesInQueue: (n) => `${n} fil${n !== 1 ? 'er' : ''} i køen.`,
|
||
done: (n, dur) => n > 1 ? `Ferdig · ${n} klipp · Total lyd: ${dur}` : `Ferdig · Lyd: ${dur}`,
|
||
traceUploadLabel: (clip, eng) => `${clip} — laster opp til ${eng}`,
|
||
traceUploadDetail: (eng) => eng === 'gpu' ? 'Sender lyd til cuttlefish GPU…' : `Sender lyd til ${eng}…`,
|
||
traceProcessingLabel: (clip, eng) => `${clip} — ${eng} transkriberer`,
|
||
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transkriberer. Store filer tar 1–3 minutter.' : `${eng} behandler lyden.`,
|
||
traceStillLabel: (clip) => `${clip} — behandler fortsatt…`,
|
||
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m} min ${s}s gått — jobber gjennom lyden.` : `${e}s gått — behandler.`; },
|
||
},
|
||
uk: {
|
||
engine: 'Рушій',
|
||
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
|
||
engineOpenaiLabel: 'OpenAI Whisper API',
|
||
engineAzureLabel: 'Azure AI Speech (nb-NO)',
|
||
apiKey: 'API-ключ',
|
||
apiKeyHint: 'Використовується лише для цього запиту, ніколи не зберігається. Макс 25 МБ.',
|
||
region: 'Регіон',
|
||
model: 'Модель',
|
||
modelFastest: 'Найшвидша',
|
||
modelBalanced: 'Збалансована',
|
||
modelBest: 'Найкраща якість',
|
||
transcribeLang: 'Мова аудіо',
|
||
autoDetectHint: '(може плутати nb/da/sv)',
|
||
speakers: 'Мовці',
|
||
identifySpeakers: 'Визначити мовців',
|
||
speakersCount: 'Кількість',
|
||
speakersPlaceholder: 'auto',
|
||
speakersAriaLabel: 'Очікувана кількість мовців',
|
||
vocabulary: 'Словник',
|
||
vocabPresetChildWelfare: 'Охорона дітей / CPS',
|
||
vocabPresetMediation: 'Медіація / зустріч',
|
||
vocabPresetGeneral: 'Загальна норвезька',
|
||
vocabPresetCustom: 'Власний',
|
||
vocabPlaceholder: 'Терміни та імена для Whisper, напр. Barnevernet, медіація…',
|
||
vocabHint: 'Допомагає Whisper розпізнати терміни. Не включається до транскрипту.',
|
||
uploadAria: 'Завантаження аудіо',
|
||
uploadDrop: 'Перетягніть файл(u) сюди, або',
|
||
uploadBrowse: 'огляд',
|
||
uploadHint: 'макс 200 МБ на файл',
|
||
uploadAddFiles: '+ Додати файли',
|
||
uploadClearQueue: '× Очистити чергу',
|
||
expertSettings: 'Розширені налаштування',
|
||
task: 'Завдання',
|
||
taskTranscribe: 'Транскрибувати',
|
||
taskTranslate: 'Перекласти англійською',
|
||
beamSize: 'Розмір пучка',
|
||
beamFastest: '(найшвидший)',
|
||
beamBest: '(найкращий)',
|
||
vadFilter: 'VAD-фільтр',
|
||
vadFilterLabel: 'Видалити тишу',
|
||
run: 'Запустити',
|
||
running: 'Транскрибування…',
|
||
runningOther: 'Виконання…',
|
||
readyTitle: 'Готово',
|
||
readyDesc: 'Виберіть інструмент, запустіть запит — результат з'явиться тут.',
|
||
noFileSelected: 'Виберіть хоч б один аудіофайл перед транскрибуванням.',
|
||
missingOpenaiKey: 'Введіть дійсний ключ OpenAI API (sk-…) перед запуском.',
|
||
openaiFileTooLarge: (f) => `OpenAI Whisper має обмеження 25 МБ. Використовуйте GPU для ${f}.`,
|
||
missingAzureKey: 'Введіть ключ Azure Speech API перед запуском.',
|
||
clipLabel: (i, total) => total > 1 ? `Кліп ${i}/${total}` : 'Транскрибування',
|
||
transcribeFailed: (s) => `Транскрибування не вдалося (HTTP ${s}).`,
|
||
errorLabel: (clip) => `Помилка – ${clip}`,
|
||
filesSkipped: (names) => `Пропущено: ${names}`,
|
||
fileSizeExceeded: (name, mb) => `${name} (${mb} МБ — макс 200 МБ)`,
|
||
filesInQueue: (n) => `${n} файл${n !== 1 ? 'ів' : ''} у черзі.`,
|
||
done: (n, dur) => n > 1 ? `Готово · ${n} кліпи · Загальне аудіо: ${dur}` : `Готово · Аудіо: ${dur}`,
|
||
traceUploadLabel: (clip, eng) => `${clip} — завантаження до ${eng}`,
|
||
traceUploadDetail: (eng) => eng === 'gpu' ? 'Відправка аудіо на cuttlefish GPU…' : `Відправка аудіо до ${eng}…`,
|
||
traceProcessingLabel: (clip, eng) => `${clip} — ${eng} транскрибує`,
|
||
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper транскрибує. Великі файли займають 1–3 хвилини.' : `${eng} обробляє аудіо.`,
|
||
traceStillLabel: (clip) => `${clip} — ще обробляється…`,
|
||
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Минуло ${m} хв ${s} с — обробка.` : `Минуло ${e} с — обробка.`; },
|
||
},
|
||
pl: {
|
||
engine: 'Silnik',
|
||
engineGpuLabel: 'GPU (cuttlefish RTX 3060)',
|
||
engineOpenaiLabel: 'OpenAI Whisper API',
|
||
engineAzureLabel: 'Azure AI Speech (nb-NO)',
|
||
apiKey: 'Klucz API',
|
||
apiKeyHint: 'Używany tylko dla tego żądania, nigdy nie przechowywany. Maks 25 MB.',
|
||
region: 'Region',
|
||
model: 'Model',
|
||
modelFastest: 'Najszybszy',
|
||
modelBalanced: 'Zrównoważony',
|
||
modelBest: 'Najlepsza jakość',
|
||
transcribeLang: 'Język audio',
|
||
autoDetectHint: '(może mylić nb/da/sv)',
|
||
speakers: 'Mówcy',
|
||
identifySpeakers: 'Rozróżnij mówców',
|
||
speakersCount: 'Liczba',
|
||
speakersPlaceholder: 'auto',
|
||
speakersAriaLabel: 'Oczekiwana liczba mówców',
|
||
vocabulary: 'Słownik',
|
||
vocabPresetChildWelfare: 'Opieka nad dziećmi / CPS',
|
||
vocabPresetMediation: 'Mediacja / spotkanie',
|
||
vocabPresetGeneral: 'Ogólny norweski',
|
||
vocabPresetCustom: 'Własny',
|
||
vocabPlaceholder: 'Terminy i nazwy dla Whisper, np. Barnevernet, mediacja…',
|
||
vocabHint: 'Pomaga Whisper rozpoznać terminy. Nie jest uwzględniony w transkrypcji.',
|
||
uploadAria: 'Prześylanie audio',
|
||
uploadDrop: 'Upuść plik(i) audio tutaj lub',
|
||
uploadBrowse: 'przeglądaj',
|
||
uploadHint: 'maks 200 MB na plik',
|
||
uploadAddFiles: '+ Dodaj pliki',
|
||
uploadClearQueue: '× Wyczyść kolejkę',
|
||
expertSettings: 'Ustawienia zaawansowane',
|
||
task: 'Zadanie',
|
||
taskTranscribe: 'Transkrybuj',
|
||
taskTranslate: 'Przetłumacz na angielski',
|
||
beamSize: 'Rozmiar wiązki',
|
||
beamFastest: '(najszybszy)',
|
||
beamBest: '(najlepszy)',
|
||
vadFilter: 'Filtr VAD',
|
||
vadFilterLabel: 'Usuń ciszę',
|
||
run: 'Uruchom',
|
||
running: 'Transkrybowanie…',
|
||
runningOther: 'Uruchamianie…',
|
||
readyTitle: 'Gotowe',
|
||
readyDesc: 'Wybierz narzędzie, uruchom żądanie — wynik pojawi się tutaj.',
|
||
noFileSelected: 'Wybierz co najmniej jeden plik audio przed transkrypcją.',
|
||
missingOpenaiKey: 'Wprowadź prawidłowy klucz API OpenAI (sk-…) przed uruchomieniem.',
|
||
openaiFileTooLarge: (f) => `OpenAI Whisper ma limit 25 MB. Użyj silnika GPU dla ${f}.`,
|
||
missingAzureKey: 'Wprowadź klucz Azure Speech API przed uruchomieniem.',
|
||
clipLabel: (i, total) => total > 1 ? `Klip ${i}/${total}` : 'Transkrybowanie',
|
||
transcribeFailed: (s) => `Transkrypcja nie powiodła się (HTTP ${s}).`,
|
||
errorLabel: (clip) => `Błąd – ${clip}`,
|
||
filesSkipped: (names) => `Pominięto: ${names}`,
|
||
fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`,
|
||
filesInQueue: (n) => `${n} plik${n !== 1 ? 'i' : ''} w kolejce.`,
|
||
done: (n, dur) => n > 1 ? `Gotowe · ${n} klipy · Łączne audio: ${dur}` : `Gotowe · Audio: ${dur}`,
|
||
traceUploadLabel: (clip, eng) => `${clip} — przesyłanie do ${eng}`,
|
||
traceUploadDetail: (eng) => eng === 'gpu' ? 'Wysyłanie audio do cuttlefish GPU…' : `Wysyłanie audio do ${eng}…`,
|
||
traceProcessingLabel: (clip, eng) => `${clip} — ${eng} transkrybuje`,
|
||
traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transkrybuje. Duże pliki zajmują 1–3 minuty.' : `${eng} przetwarza audio.`,
|
||
traceStillLabel: (clip) => `${clip} — nadal przetwarza…`,
|
||
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Minęło ${m} min ${s} s — przetwarzanie audio.` : `Minęło ${e} s — przetwarzanie.`; },
|
||
},
|
||
};
|
||
|
||
function currentUiT(key, ...args) {
|
||
const t = TRANSCRIBE_I18N[uiLang] || TRANSCRIBE_I18N.en;
|
||
const val = (key in t) ? t[key] : TRANSCRIBE_I18N.en[key];
|
||
if (typeof val === 'function') return val(...args);
|
||
return val ?? key;
|
||
}
|
||
|
||
function applyTranscribeI18n(lang) {
|
||
uiLang = lang;
|
||
localStorage.setItem('dbn-ui-lang', lang);
|
||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||
const text = currentUiT(el.dataset.i18n);
|
||
if (text != null) el.textContent = text;
|
||
});
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
|
||
const text = currentUiT(el.dataset.i18nPlaceholder);
|
||
if (text != null) el.placeholder = text;
|
||
});
|
||
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
|
||
const text = currentUiT(el.dataset.i18nAria);
|
||
if (text != null) el.setAttribute('aria-label', text);
|
||
});
|
||
document.querySelectorAll('.lang-btn').forEach((btn) => {
|
||
btn.classList.toggle('is-active', btn.dataset.lang === lang);
|
||
});
|
||
}
|
||
|
||
const tools = {
|
||
ask: {
|
||
kind: 'Source-grounded Legal Ask',
|
||
title: 'Ask a legal question',
|
||
label: 'Question',
|
||
endpoint: 'api/ask.php',
|
||
payloadKey: 'question',
|
||
placeholder: 'Example: What evidence is needed before asking for changes in custody arrangements?',
|
||
usesLanguage: true,
|
||
badge: 'family-legal',
|
||
},
|
||
search: {
|
||
kind: 'Legal Source Search',
|
||
title: 'Search legal sources',
|
||
label: 'Search query',
|
||
endpoint: 'api/search.php',
|
||
payloadKey: 'query',
|
||
placeholder: 'Example: barnets beste samvær foreldreansvar',
|
||
usesLanguage: true,
|
||
badge: 'family-legal',
|
||
},
|
||
summarize: {
|
||
kind: 'Document Summarizer',
|
||
title: 'Summarize pasted text',
|
||
label: 'Pasted text',
|
||
endpoint: 'api/summarize.php',
|
||
payloadKey: 'text',
|
||
placeholder: 'Paste a case note, letter, or excerpt.',
|
||
usesLanguage: true,
|
||
badge: 'process-and-forget',
|
||
},
|
||
timeline: {
|
||
kind: 'Timeline Builder',
|
||
title: 'Build a timeline',
|
||
label: 'Pasted text',
|
||
endpoint: 'api/timeline.php',
|
||
payloadKey: 'text',
|
||
placeholder: 'Paste case notes with dates, actors, and events.',
|
||
usesLanguage: true,
|
||
badge: 'process-and-forget',
|
||
},
|
||
redact: {
|
||
kind: 'Redaction Assistant',
|
||
title: 'Redact sensitive details',
|
||
label: 'Pasted text',
|
||
endpoint: 'api/redact.php',
|
||
payloadKey: 'text',
|
||
placeholder: 'Paste text containing names, phone numbers, emails, addresses, or fødselsnummer-like values.',
|
||
usesLanguage: false,
|
||
badge: 'deterministic first',
|
||
},
|
||
transcribe: {
|
||
kind: 'Audio Transcription',
|
||
title: 'Transcribe audio',
|
||
label: 'Audio file',
|
||
endpoint: 'api/transcribe.php',
|
||
payloadKey: null,
|
||
placeholder: '',
|
||
usesLanguage: false,
|
||
badge: 'Whisper / GPU',
|
||
},
|
||
};
|
||
|
||
const els = {};
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
Object.assign(els, {
|
||
gate: document.querySelector('#publicLanding'),
|
||
app: document.querySelector('#appShell'),
|
||
passcodeForm: document.querySelector('#passcodeForm'),
|
||
loginEmail: document.querySelector('#loginEmail'),
|
||
loginPassword: document.querySelector('#loginPassword'),
|
||
gateStatus: document.querySelector('#gateStatus'),
|
||
tabs: Array.from(document.querySelectorAll('.tool-tab')),
|
||
toolKind: document.querySelector('#toolKind'),
|
||
toolTitle: document.querySelector('#toolTitle'),
|
||
toolBadge: document.querySelector('#toolBadge'),
|
||
form: document.querySelector('#toolForm'),
|
||
inputLabel: document.querySelector('#inputLabel'),
|
||
input: document.querySelector('#toolInput'),
|
||
languageControl: document.querySelector('#languageControl'),
|
||
redactionControl: document.querySelector('#redactionControl'),
|
||
status: document.querySelector('#toolStatus'),
|
||
results: document.querySelector('#results'),
|
||
traceList: document.querySelector('#traceList'),
|
||
healthButton: document.querySelector('#healthButton'),
|
||
healthPill: document.querySelector('#healthPill'),
|
||
uploadZone: document.querySelector('#uploadZone'),
|
||
uploadInput: document.querySelector('#uploadInput'),
|
||
uploadPrompt: document.querySelector('#uploadPrompt'),
|
||
uploadFileInfo: document.querySelector('#uploadFileInfo'),
|
||
uploadFileList: document.querySelector('#uploadFileList'),
|
||
uploadClear: document.querySelector('#uploadClear'),
|
||
aliasSection: document.querySelector('#aliasSection'),
|
||
addAliasRow: document.querySelector('#addAliasRow'),
|
||
aliasRows: document.querySelector('#aliasRows'),
|
||
audioZone: document.querySelector('#audioZone'),
|
||
audioInput: document.querySelector('#audioInput'),
|
||
audioPrompt: document.querySelector('#audioPrompt'),
|
||
audioFileInfo: document.querySelector('#audioFileInfo'),
|
||
audioQueueList: document.querySelector('#audioQueueList'),
|
||
audioClear: document.querySelector('#audioClear'),
|
||
diarizeControl: document.querySelector('#diarizeControl'),
|
||
diarizeCheck: document.querySelector('#diarizeCheck'),
|
||
numSpeakersInput: document.querySelector('#numSpeakersInput'),
|
||
transcribeLangControl: document.querySelector('#transcribeLangControl'),
|
||
initPromptInput: document.querySelector('#initPromptInput'),
|
||
vocabPresets: document.querySelector('#vocabPresets'),
|
||
});
|
||
|
||
els.tabs.forEach((tab) => {
|
||
if (tab.tagName !== 'A') {
|
||
tab.addEventListener('click', () => setTool(tab.dataset.tool));
|
||
}
|
||
});
|
||
els.form?.addEventListener('submit', runTool);
|
||
els.passcodeForm?.addEventListener('submit', submitPasscode);
|
||
els.healthButton.addEventListener('click', checkHealth);
|
||
setupUpload();
|
||
setupAliases();
|
||
setupAudio();
|
||
setupTranscribeControls();
|
||
setupVocabPresets();
|
||
document.querySelectorAll('.lang-btn').forEach((btn) => {
|
||
btn.addEventListener('click', () => applyTranscribeI18n(btn.dataset.lang));
|
||
});
|
||
applyTranscribeI18n(uiLang);
|
||
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();
|
||
});
|
||
const activeTool = document.body.dataset.activeTool || state.activeTool;
|
||
setTool(activeTool);
|
||
|
||
if (state.authenticated) {
|
||
checkHealth();
|
||
} else {
|
||
els.loginEmail?.focus();
|
||
}
|
||
});
|
||
|
||
function setTool(toolName) {
|
||
state.activeTool = toolName;
|
||
const tool = tools[toolName];
|
||
els.tabs.forEach((button) => {
|
||
const active = button.dataset.tool === toolName;
|
||
button.classList.toggle('is-active', active);
|
||
button.setAttribute('aria-pressed', String(active));
|
||
});
|
||
|
||
els.toolKind.textContent = tool.kind;
|
||
els.toolTitle.textContent = tool.title;
|
||
els.toolBadge.textContent = tool.badge;
|
||
els.inputLabel.textContent = tool.label;
|
||
els.input.value = '';
|
||
els.input.placeholder = tool.placeholder;
|
||
els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage);
|
||
els.redactionControl.classList.toggle('is-hidden', toolName !== 'redact');
|
||
els.uploadZone.classList.toggle('is-hidden', toolName !== 'redact' && toolName !== 'timeline');
|
||
els.aliasSection.classList.toggle('is-hidden', toolName !== 'redact');
|
||
els.audioZone.classList.toggle('is-hidden', toolName !== 'transcribe');
|
||
els.diarizeControl.classList.toggle('is-hidden', toolName !== 'transcribe');
|
||
els.transcribeLangControl.classList.toggle('is-hidden', toolName !== 'transcribe');
|
||
els.input.classList.toggle('is-hidden', toolName === 'transcribe');
|
||
els.inputLabel.classList.toggle('is-hidden', toolName === 'transcribe');
|
||
els.input.required = toolName !== 'transcribe';
|
||
resetUpload();
|
||
resetAliases();
|
||
resetAudio();
|
||
els.status.textContent = '';
|
||
renderTrace([]);
|
||
}
|
||
|
||
async function submitPasscode(event) {
|
||
event.preventDefault();
|
||
els.gateStatus.textContent = 'Signing in…';
|
||
try {
|
||
const data = await postJson('api/session.php', {
|
||
email: els.loginEmail.value.trim(),
|
||
password: els.loginPassword.value,
|
||
});
|
||
if (!data.ok) {
|
||
throw new Error(data.error?.message || 'Credentials were not accepted.');
|
||
}
|
||
state.authenticated = true;
|
||
els.gate.classList.add('is-hidden');
|
||
els.app.classList.remove('is-hidden');
|
||
els.loginPassword.value = '';
|
||
els.healthPill.textContent = 'Session active';
|
||
checkHealth();
|
||
els.input.focus();
|
||
} catch (error) {
|
||
els.gateStatus.textContent = error.message;
|
||
}
|
||
}
|
||
|
||
async function runTool(event) {
|
||
event.preventDefault();
|
||
|
||
if (state.activeTool === 'transcribe') {
|
||
await runTranscribe();
|
||
return;
|
||
}
|
||
|
||
const tool = tools[state.activeTool];
|
||
const text = els.input.value.trim();
|
||
if (!text) {
|
||
els.status.textContent = 'Add text before running the tool.';
|
||
els.input.focus();
|
||
return;
|
||
}
|
||
|
||
const payload = { [tool.payloadKey]: text };
|
||
if (tool.usesLanguage) {
|
||
payload.language = currentLanguage();
|
||
}
|
||
if (state.activeTool === 'search') {
|
||
payload.limit = 7;
|
||
}
|
||
if (state.activeTool === 'redact') {
|
||
payload.mode = currentRedactionMode();
|
||
payload.region = currentRedactionRegion();
|
||
payload.aliases = getAliases();
|
||
}
|
||
|
||
setBusy(true);
|
||
renderTrace([
|
||
{ label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
|
||
]);
|
||
|
||
try {
|
||
const data = await postJson(tool.endpoint, payload);
|
||
if (!data.ok) {
|
||
throw new Error(data.error?.message || 'Tool request 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 resetUpload() {
|
||
if (!els.uploadInput) return;
|
||
els.uploadInput.value = '';
|
||
els.uploadPrompt.classList.remove('is-hidden');
|
||
els.uploadFileInfo.classList.add('is-hidden');
|
||
els.uploadFileList.innerHTML = '';
|
||
els.uploadZone.classList.remove('is-drag-over');
|
||
}
|
||
|
||
function setupUpload() {
|
||
els.uploadZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
els.uploadZone.classList.add('is-drag-over');
|
||
});
|
||
|
||
els.uploadZone.addEventListener('dragleave', (e) => {
|
||
if (!els.uploadZone.contains(e.relatedTarget)) {
|
||
els.uploadZone.classList.remove('is-drag-over');
|
||
}
|
||
});
|
||
|
||
els.uploadZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
els.uploadZone.classList.remove('is-drag-over');
|
||
if (e.dataTransfer?.files?.length) handleFiles(e.dataTransfer.files);
|
||
});
|
||
|
||
els.uploadZone.addEventListener('click', (e) => {
|
||
if (e.target === els.uploadClear || els.uploadClear?.contains(e.target)) return;
|
||
if (e.target.tagName === 'LABEL') return;
|
||
els.uploadInput.click();
|
||
});
|
||
|
||
els.uploadInput.addEventListener('change', () => {
|
||
if (els.uploadInput.files?.length) handleFiles(els.uploadInput.files);
|
||
});
|
||
|
||
els.uploadClear.addEventListener('click', () => {
|
||
resetUpload();
|
||
els.input.value = '';
|
||
els.status.textContent = '';
|
||
});
|
||
}
|
||
|
||
async function handleFiles(fileList) {
|
||
const allowed = ['pdf', 'docx', 'txt'];
|
||
const files = Array.from(fileList).slice(0, 5);
|
||
|
||
for (const file of files) {
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
if (!allowed.includes(ext)) {
|
||
els.status.textContent = `Skipped ${file.name}: unsupported type. Use .pdf, .docx, or .txt.`;
|
||
return;
|
||
}
|
||
}
|
||
|
||
els.status.textContent = files.length === 1 ? `Extracting ${files[0].name}…` : `Extracting ${files.length} files…`;
|
||
setBusy(true);
|
||
|
||
const parts = [];
|
||
let totalChars = 0;
|
||
let anyTruncated = false;
|
||
|
||
try {
|
||
for (const file of files) {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const resp = await fetch('api/extract.php', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
body: formData,
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
|
||
if (!resp.ok || !data.ok) {
|
||
throw new Error(data.error?.message || `Extraction failed for ${file.name} (HTTP ${resp.status}).`);
|
||
}
|
||
|
||
parts.push({ filename: file.name, chars: data.chars, truncated: data.truncated, text: data.text });
|
||
totalChars += data.chars;
|
||
if (data.truncated) anyTruncated = true;
|
||
}
|
||
|
||
const combined = parts.length === 1
|
||
? parts[0].text
|
||
: parts.map((p) => `--- Document: ${p.filename} ---\n\n${p.text}`).join('\n\n');
|
||
|
||
const MAX_COMBINED = 128000;
|
||
const combinedTruncated = combined.length > MAX_COMBINED;
|
||
els.input.value = combinedTruncated ? combined.slice(0, MAX_COMBINED) : combined;
|
||
|
||
els.uploadFileList.innerHTML = parts
|
||
.map((p) => `<li><span class="upload-filename">${escapeHtml(p.filename)}</span><span class="upload-chars">${p.chars.toLocaleString()} chars${p.truncated ? ' • per-file limit reached' : ''}</span></li>`)
|
||
.join('');
|
||
els.uploadPrompt.classList.add('is-hidden');
|
||
els.uploadFileInfo.classList.remove('is-hidden');
|
||
|
||
const truncNote = (anyTruncated || combinedTruncated) ? ' — truncated to 128 000 char limit' : '';
|
||
els.status.textContent = parts.length === 1
|
||
? `Extracted ${totalChars.toLocaleString()} chars from ${parts[0].filename}${truncNote}.`
|
||
: `Extracted ${totalChars.toLocaleString()} chars total from ${parts.length} files${truncNote}.`;
|
||
} catch (err) {
|
||
els.status.textContent = err.message;
|
||
resetUpload();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function setupAliases() {
|
||
els.addAliasRow.addEventListener('click', () => {
|
||
const row = document.createElement('div');
|
||
row.className = 'alias-row';
|
||
row.innerHTML = [
|
||
'<input type="text" class="alias-original" placeholder="Real name" maxlength="100">',
|
||
'<span class="alias-arrow" aria-hidden="true">→</span>',
|
||
'<input type="text" class="alias-label" placeholder="Alias (without brackets)" maxlength="100">',
|
||
'<button type="button" class="alias-remove" aria-label="Remove alias">×</button>',
|
||
].join('');
|
||
els.aliasRows.appendChild(row);
|
||
row.querySelector('.alias-original').focus();
|
||
});
|
||
|
||
els.aliasRows.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.alias-remove');
|
||
if (btn) btn.closest('.alias-row').remove();
|
||
});
|
||
}
|
||
|
||
function getAliases() {
|
||
return Array.from(els.aliasRows.querySelectorAll('.alias-row')).flatMap((row) => {
|
||
const original = row.querySelector('.alias-original')?.value.trim() ?? '';
|
||
const alias = row.querySelector('.alias-label')?.value.trim() ?? '';
|
||
return original && alias ? [{ original, alias }] : [];
|
||
});
|
||
}
|
||
|
||
function resetAliases() {
|
||
if (els.aliasRows) els.aliasRows.innerHTML = '';
|
||
}
|
||
|
||
async function checkHealth() {
|
||
els.healthPill.textContent = 'Checking...';
|
||
try {
|
||
const response = await fetch('api/health.php', {
|
||
method: 'GET',
|
||
headers: { Accept: 'application/json' },
|
||
credentials: 'same-origin',
|
||
});
|
||
const data = await response.json();
|
||
els.healthPill.textContent = data.ok ? 'Healthy' : 'Needs config';
|
||
els.healthPill.classList.toggle('is-warning', !data.ok);
|
||
if (!data.ok && data.checks) {
|
||
renderHealth(data);
|
||
}
|
||
} catch (error) {
|
||
els.healthPill.textContent = 'Health failed';
|
||
els.healthPill.classList.add('is-warning');
|
||
}
|
||
}
|
||
|
||
async function postJson(url, payload) {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(data.error?.message || `Request failed with HTTP ${response.status}.`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function setBusy(isBusy) {
|
||
const button = document.querySelector('#runButton');
|
||
button.disabled = isBusy;
|
||
button.textContent = isBusy
|
||
? (state.activeTool === 'transcribe' ? currentUiT('running') : currentUiT('runningOther'))
|
||
: currentUiT('run');
|
||
}
|
||
|
||
function currentLanguage() {
|
||
return document.querySelector('input[name="language"]:checked')?.value || 'en';
|
||
}
|
||
|
||
function currentRedactionMode() {
|
||
return document.querySelector('input[name="redactionMode"]:checked')?.value || 'standard';
|
||
}
|
||
|
||
function currentRedactionRegion() {
|
||
return document.querySelector('input[name="redactionRegion"]:checked')?.value || 'nordic';
|
||
}
|
||
|
||
function renderResults(data) {
|
||
const sections = [];
|
||
sections.push(sectionHtml('What We Found', renderMainFinding(data)));
|
||
sections.push(sectionHtml('Evidence Trail', renderEvidence(data)));
|
||
sections.push(sectionHtml('What Remains Uncertain', renderListish(data.what_remains_uncertain)));
|
||
sections.push(sectionHtml('Next Practical Step', `<p>${escapeHtml(data.next_practical_step || 'Review the evidence trail.')}</p>`));
|
||
|
||
if (data.disclaimer) {
|
||
sections.push(`<p class="result-disclaimer">${escapeHtml(data.disclaimer)}</p>`);
|
||
}
|
||
|
||
els.results.innerHTML = sections.join('');
|
||
}
|
||
|
||
function renderMainFinding(data) {
|
||
if (data.tool === 'ask') {
|
||
return `<p class="answer">${escapeHtml(data.answer || data.what_we_found || '')}</p>`;
|
||
}
|
||
if (data.tool === 'redact') {
|
||
return `<pre class="redacted-output">${escapeHtml(data.redacted_text || '')}</pre>${renderEntityCounts(data.entity_counts)}`;
|
||
}
|
||
if (data.tool === 'timeline') {
|
||
lastTimelineEvents = data.events || [];
|
||
const csvBtn = lastTimelineEvents.length
|
||
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">Download CSV</button></div>`
|
||
: '';
|
||
return `<p>${escapeHtml(data.what_we_found || '')}</p>${renderTimeline(lastTimelineEvents)}${csvBtn}`;
|
||
}
|
||
if (data.tool === 'summarize') {
|
||
return [
|
||
`<p>${escapeHtml(data.what_we_found || '')}</p>`,
|
||
detailList('Key Facts', data.key_facts),
|
||
detailList('Dates', data.dates),
|
||
detailList('Parties', data.parties),
|
||
detailList('Legal References Detected', data.legal_references_detected),
|
||
].join('');
|
||
}
|
||
if (data.tool === 'search') {
|
||
return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
|
||
}
|
||
return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
|
||
}
|
||
|
||
function currentTranscribeLang() {
|
||
return document.querySelector('input[name="transcribeLang"]:checked')?.value || 'auto';
|
||
}
|
||
|
||
function renderEvidence(data) {
|
||
const items = data.evidence_trail || data.sources || data.hits || [];
|
||
if (!items.length) {
|
||
return '<p>No evidence trail was available for this request.</p>';
|
||
}
|
||
return `<div class="source-list">${items.map(renderEvidenceItem).join('')}</div>`;
|
||
}
|
||
|
||
function renderEvidenceItem(item) {
|
||
const title = item.title || item.citation || 'Source';
|
||
const body = item.excerpt || item.why_it_matters || item.citation || '';
|
||
const chunkText = item.chunk_text || '';
|
||
const meta = [
|
||
item.package_or_corpus,
|
||
item.section,
|
||
item.score !== undefined && item.score !== null ? `score ${item.score}` : '',
|
||
].filter(Boolean).join(' · ');
|
||
|
||
const chunkToggle = (chunkText && chunkText !== body) ? `
|
||
<details class="chunk-details">
|
||
<summary class="chunk-toggle">View chunk</summary>
|
||
<pre class="chunk-text">${escapeHtml(chunkText)}</pre>
|
||
</details>
|
||
` : '';
|
||
|
||
return `
|
||
<article class="source-card">
|
||
<h4>${escapeHtml(title)}</h4>
|
||
${meta ? `<p class="source-meta">${escapeHtml(meta)}</p>` : ''}
|
||
<p>${escapeHtml(body)}</p>
|
||
${chunkToggle}
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderTimeline(events) {
|
||
if (!events.length) {
|
||
return '<p>No events were identified.</p>';
|
||
}
|
||
return `<ol class="timeline-list">${events.map((ev) => `
|
||
<li>
|
||
<strong>${escapeHtml(ev.date || 'unknown')}</strong>
|
||
${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''}
|
||
<span>${escapeHtml(ev.actor || 'unknown actor')}</span>
|
||
<p>${escapeHtml(ev.event || '')}</p>
|
||
${ev.source_excerpt ? `<small>${escapeHtml(ev.source_excerpt)}</small>` : ''}
|
||
</li>
|
||
`).join('')}</ol>`;
|
||
}
|
||
|
||
function exportTimelineCSV(events) {
|
||
const header = ['Date', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence'];
|
||
const rows = events.map((ev) => [
|
||
ev.date || '', ev.date_type || '', ev.actor || '',
|
||
ev.event || '', ev.source_excerpt || '', ev.confidence || '',
|
||
]);
|
||
const csv = [header, ...rows]
|
||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||
.join('\n');
|
||
const blob = new Blob([csv], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = Object.assign(document.createElement('a'), { href: url, download: 'timeline.csv' });
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function currentTranscribeEngine() {
|
||
const el = document.querySelector('input[name="engine"]:checked');
|
||
return el ? el.value : 'gpu';
|
||
}
|
||
function currentTranscribeModel() {
|
||
const el = document.querySelector('input[name="model"]:checked');
|
||
return el ? el.value : 'small';
|
||
}
|
||
function currentBeamSize() {
|
||
const el = document.querySelector('input[name="beam_size"]:checked');
|
||
return el ? el.value : '5';
|
||
}
|
||
function currentTask() {
|
||
const el = document.querySelector('input[name="task"]:checked');
|
||
return el ? el.value : 'transcribe';
|
||
}
|
||
|
||
async function runTranscribe() {
|
||
if (!audioQueue.length) {
|
||
els.status.textContent = currentUiT('noFileSelected');
|
||
return;
|
||
}
|
||
|
||
const engine = currentTranscribeEngine();
|
||
|
||
if (engine === 'openai') {
|
||
const key = document.getElementById('openaiKeyInput')?.value?.trim();
|
||
if (!key || !key.startsWith('sk-')) {
|
||
els.status.textContent = currentUiT('missingOpenaiKey');
|
||
return;
|
||
}
|
||
const oversized = audioQueue.find((item) => item.file.size > 25 * 1024 * 1024);
|
||
if (oversized) {
|
||
els.status.textContent = currentUiT('openaiFileTooLarge', oversized.file.name);
|
||
return;
|
||
}
|
||
}
|
||
if (engine === 'azure') {
|
||
const key = document.getElementById('azureKeyInput')?.value?.trim();
|
||
if (!key) {
|
||
els.status.textContent = currentUiT('missingAzureKey');
|
||
return;
|
||
}
|
||
}
|
||
|
||
setBusy(true);
|
||
|
||
const initPrompt = els.initPromptInput?.value?.trim() || '';
|
||
const diarize = els.diarizeCheck?.checked ?? false;
|
||
const numSpeakers = parseInt(els.numSpeakersInput?.value || '', 10);
|
||
const vadFilter = document.getElementById('vadFilterCheck')?.checked ?? false;
|
||
const total = audioQueue.length;
|
||
|
||
// Reset all items to pending before starting
|
||
audioQueue.forEach((item) => { item.status = 'pending'; item.result = null; });
|
||
renderAudioQueue();
|
||
|
||
let cumulativeOffset = 0;
|
||
let allTranscripts = [];
|
||
let allSegments = [];
|
||
let firstSpeakerRoles = null;
|
||
let lastResult = null;
|
||
|
||
for (let i = 0; i < audioQueue.length; i++) {
|
||
const item = audioQueue[i];
|
||
item.status = 'processing';
|
||
renderAudioQueue();
|
||
|
||
const startTime = Date.now();
|
||
let elapsed = 0;
|
||
const clipLabel = currentUiT('clipLabel', i + 1, total);
|
||
els.status.textContent = `${clipLabel}…`;
|
||
|
||
const timer = setInterval(() => {
|
||
elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
const m = Math.floor(elapsed / 60);
|
||
const s = elapsed % 60;
|
||
const t = m > 0 ? `${m}:${pad2(s)}` : `${s}s`;
|
||
els.status.textContent = `${clipLabel}… ${t}`;
|
||
updateTranscribeTrace(elapsed, engine, clipLabel);
|
||
}, 1000);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('audio', item.file);
|
||
formData.append('engine', engine);
|
||
formData.append('language', currentTranscribeLang());
|
||
formData.append('model', currentTranscribeModel());
|
||
formData.append('beam_size', currentBeamSize());
|
||
formData.append('task', currentTask());
|
||
formData.append('time_offset', String(cumulativeOffset));
|
||
if (vadFilter) formData.append('vad_filter', '1');
|
||
if (initPrompt) formData.append('initial_prompt', initPrompt);
|
||
if (diarize) {
|
||
formData.append('diarize', '1');
|
||
if (numSpeakers >= 2) formData.append('num_speakers', String(numSpeakers));
|
||
}
|
||
if (engine === 'openai') {
|
||
formData.append('openai_key', document.getElementById('openaiKeyInput')?.value?.trim());
|
||
}
|
||
if (engine === 'azure') {
|
||
formData.append('azure_key', document.getElementById('azureKeyInput')?.value?.trim());
|
||
formData.append('azure_region', document.getElementById('azureRegionInput')?.value?.trim() || 'norwayeast');
|
||
}
|
||
|
||
const resp = await fetch('api/transcribe.php', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
body: formData,
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data.ok) {
|
||
throw new Error(data.error?.message || currentUiT('transcribeFailed', resp.status));
|
||
}
|
||
|
||
clearInterval(timer);
|
||
item.status = 'done';
|
||
item.result = data;
|
||
lastResult = data;
|
||
|
||
allTranscripts.push(data.transcript || '');
|
||
allSegments.push(...(data.segments || []));
|
||
if (!firstSpeakerRoles && data.speaker_roles && Object.keys(data.speaker_roles).length) {
|
||
firstSpeakerRoles = data.speaker_roles;
|
||
}
|
||
|
||
// Advance offset by this clip's duration (fall back to file-size estimate at 128 kbps)
|
||
cumulativeOffset += data.duration_sec > 0
|
||
? data.duration_sec
|
||
: item.file.size / (128 * 1024 / 8);
|
||
|
||
} catch (err) {
|
||
clearInterval(timer);
|
||
item.status = 'error';
|
||
renderAudioQueue();
|
||
els.status.textContent = `${clipLabel}: ${err.message}`;
|
||
renderTrace([{ label: currentUiT('errorLabel', clipLabel), detail: err.message, status: 'warning' }]);
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
|
||
renderAudioQueue();
|
||
}
|
||
|
||
// Merge results
|
||
const merged = {
|
||
...lastResult,
|
||
transcript: allTranscripts.join('\n\n'),
|
||
segments: allSegments,
|
||
speaker_roles: firstSpeakerRoles,
|
||
num_speakers: lastResult?.num_speakers ?? 0,
|
||
duration_sec: cumulativeOffset,
|
||
};
|
||
|
||
lastTranscriptData = merged;
|
||
renderTranscriptResults(merged);
|
||
|
||
const totalSec = Math.round(cumulativeOffset);
|
||
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);
|
||
setBusy(false);
|
||
}
|
||
|
||
function updateTranscribeTrace(elapsed, engine, clipLabel) {
|
||
if (!clipLabel) clipLabel = currentUiT('clipLabel', 1, 1);
|
||
const engineLabel = engine === 'openai' ? 'OpenAI API' : engine === 'azure' ? 'Azure Speech' : 'Whisper GPU';
|
||
let label, detail;
|
||
if (elapsed < 10) {
|
||
label = currentUiT('traceUploadLabel', clipLabel, engineLabel);
|
||
detail = currentUiT('traceUploadDetail', engine);
|
||
} else if (elapsed < 60) {
|
||
label = currentUiT('traceProcessingLabel', clipLabel, engineLabel);
|
||
detail = currentUiT('traceProcessingDetail', engine);
|
||
} else {
|
||
label = currentUiT('traceStillLabel', clipLabel);
|
||
detail = currentUiT('traceStillDetail', elapsed);
|
||
}
|
||
renderTrace([{ label, detail, status: 'running' }]);
|
||
}
|
||
|
||
function renderTranscriptResults(data) {
|
||
const speakerRoles = data.speaker_roles || {};
|
||
const segments = data.segments || [];
|
||
const hasSpeakers = segments.some((s) => s.speaker);
|
||
|
||
const speakerOrder = [...new Set(segments.filter((s) => s.speaker).map((s) => s.speaker))];
|
||
|
||
const rolesHtml = speakerOrder.length
|
||
? `<p class="transcript-roles">${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('')}</p>`
|
||
: '';
|
||
|
||
const segmentsHtml = hasSpeakers
|
||
? `<details class="segment-details"><summary class="segment-summary">Segments (${segments.length})</summary>
|
||
<div class="segment-list">${segments.map((seg) => {
|
||
const idx = speakerOrder.indexOf(seg.speaker);
|
||
const roleLabel = seg.speaker && speakerRoles[seg.speaker]
|
||
? `${speakerRoles[seg.speaker]} (${seg.speaker})`
|
||
: (seg.speaker || '');
|
||
return `<div class="segment-row">
|
||
<span class="segment-time">${fmtTime(seg.start)}–${fmtTime(seg.end)}</span>
|
||
${seg.speaker ? `<span class="speaker-tag speaker-tag--${idx >= 0 ? idx % 6 : 0}">${escapeHtml(roleLabel)}</span>` : ''}
|
||
<span class="segment-text">${escapeHtml(seg.text)}</span>
|
||
</div>`;
|
||
}).join('')}</div></details>`
|
||
: '';
|
||
|
||
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>`
|
||
: '';
|
||
|
||
els.results.innerHTML = `
|
||
<section class="result-section">
|
||
<h3>Transcript</h3>
|
||
${rolesHtml}
|
||
<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>
|
||
</section>`;
|
||
|
||
const traceMeta = [];
|
||
if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, 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.model) traceMeta.push({ label: `Model: ${data.model}`, detail: '', status: 'complete' });
|
||
renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]);
|
||
}
|
||
|
||
function fmtTime(secs) {
|
||
const h = Math.floor(secs / 3600);
|
||
const m = Math.floor((secs % 3600) / 60);
|
||
const s = Math.floor(secs % 60);
|
||
const parts = h > 0 ? [pad2(h), pad2(m), pad2(s)] : [pad2(m), pad2(s)];
|
||
return parts.join(':');
|
||
}
|
||
|
||
function pad2(n) { return String(n).padStart(2, '0'); }
|
||
|
||
function toSrtTime(secs) {
|
||
const h = Math.floor(secs / 3600);
|
||
const m = Math.floor((secs % 3600) / 60);
|
||
const s = Math.floor(secs % 60);
|
||
const ms = Math.round((secs % 1) * 1000);
|
||
return `${pad2(h)}:${pad2(m)}:${pad2(s)},${String(ms).padStart(3, '0')}`;
|
||
}
|
||
|
||
function toVttTime(secs) {
|
||
return toSrtTime(secs).replace(',', '.');
|
||
}
|
||
|
||
function downloadBlob(blob, filename) {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function downloadTranscriptTxt() {
|
||
if (!lastTranscriptData) return;
|
||
downloadBlob(new Blob([lastTranscriptData.transcript], { type: 'text/plain' }), 'transcript.txt');
|
||
}
|
||
|
||
function downloadTranscriptSrt() {
|
||
if (!lastTranscriptData?.segments?.length) return;
|
||
const { segments, speaker_roles: roles = {} } = lastTranscriptData;
|
||
const lines = segments.map((seg, i) => {
|
||
const spk = seg.speaker ? `[${roles[seg.speaker] || seg.speaker}] ` : '';
|
||
return `${i + 1}\n${toSrtTime(seg.start)} --> ${toSrtTime(seg.end)}\n${spk}${seg.text}\n`;
|
||
});
|
||
downloadBlob(new Blob([lines.join('\n')], { type: 'text/srt' }), 'transcript.srt');
|
||
}
|
||
|
||
function downloadTranscriptVtt() {
|
||
if (!lastTranscriptData?.segments?.length) return;
|
||
const { segments, speaker_roles: roles = {} } = lastTranscriptData;
|
||
const lines = ['WEBVTT\n'];
|
||
segments.forEach((seg) => {
|
||
const spk = seg.speaker ? `<v ${roles[seg.speaker] || seg.speaker}>` : '';
|
||
lines.push(`${toVttTime(seg.start)} --> ${toVttTime(seg.end)}\n${spk}${seg.text}\n`);
|
||
});
|
||
downloadBlob(new Blob([lines.join('\n')], { type: 'text/vtt' }), 'transcript.vtt');
|
||
}
|
||
|
||
function resetAudio() {
|
||
audioQueue = [];
|
||
if (!els.audioInput) return;
|
||
els.audioInput.value = '';
|
||
if (els.audioPrompt) els.audioPrompt.classList.remove('is-hidden');
|
||
if (els.audioFileInfo) els.audioFileInfo.classList.add('is-hidden');
|
||
if (els.audioQueueList) els.audioQueueList.innerHTML = '';
|
||
}
|
||
|
||
function setupAudio() {
|
||
if (!els.audioZone) return;
|
||
|
||
els.audioZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
els.audioZone.classList.add('is-drag-over');
|
||
});
|
||
|
||
els.audioZone.addEventListener('dragleave', (e) => {
|
||
if (!els.audioZone.contains(e.relatedTarget)) {
|
||
els.audioZone.classList.remove('is-drag-over');
|
||
}
|
||
});
|
||
|
||
els.audioZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
els.audioZone.classList.remove('is-drag-over');
|
||
if (e.dataTransfer?.files?.length) handleAudioFiles(e.dataTransfer.files);
|
||
});
|
||
|
||
els.audioZone.addEventListener('click', (e) => {
|
||
if (e.target === els.audioClear || els.audioClear?.contains(e.target)) return;
|
||
if (e.target === els.audioInput) return;
|
||
if (e.target.tagName === 'LABEL') return;
|
||
if (e.target.closest('#audioFileInfo') && e.target.tagName !== 'LABEL') return;
|
||
els.audioInput.click();
|
||
});
|
||
|
||
els.audioInput.addEventListener('change', () => {
|
||
if (els.audioInput.files?.length) handleAudioFiles(els.audioInput.files);
|
||
els.audioInput.value = '';
|
||
});
|
||
|
||
els.audioClear.addEventListener('click', () => {
|
||
resetAudio();
|
||
els.status.textContent = '';
|
||
});
|
||
}
|
||
|
||
function setupTranscribeControls() {
|
||
document.querySelectorAll('input[name="engine"]').forEach((radio) => {
|
||
radio.addEventListener('change', () => {
|
||
const engine = currentTranscribeEngine();
|
||
document.getElementById('openaiKeyControl')?.classList.toggle('is-hidden', engine !== 'openai');
|
||
document.getElementById('azureKeyControl')?.classList.toggle('is-hidden', engine !== 'azure');
|
||
document.getElementById('modelControl')?.classList.toggle('is-hidden', engine === 'openai' || engine === 'azure');
|
||
});
|
||
});
|
||
}
|
||
|
||
function setupVocabPresets() {
|
||
if (!els.vocabPresets) return;
|
||
els.vocabPresets.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.vocab-btn');
|
||
if (!btn) return;
|
||
const preset = btn.dataset.preset;
|
||
if (preset && els.initPromptInput) {
|
||
els.initPromptInput.value = VOCAB_PRESETS[preset] ?? '';
|
||
els.vocabPresets.querySelectorAll('.vocab-btn').forEach((b) => b.classList.remove('is-active'));
|
||
btn.classList.add('is-active');
|
||
if (preset !== 'custom') els.initPromptInput.focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleAudioFiles(fileList) {
|
||
const allowedExts = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'mp4', 'flac', 'webm', 'aac'];
|
||
let added = 0;
|
||
let skipped = [];
|
||
|
||
Array.from(fileList).forEach((file) => {
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
if (!allowedExts.includes(ext)) {
|
||
skipped.push(file.name);
|
||
return;
|
||
}
|
||
const sizeMB = file.size / 1024 / 1024;
|
||
if (sizeMB > 200) {
|
||
skipped.push(currentUiT('fileSizeExceeded', file.name, sizeMB.toFixed(1)));
|
||
return;
|
||
}
|
||
audioQueue.push({ file, status: 'pending', result: null });
|
||
added++;
|
||
});
|
||
|
||
if (skipped.length) {
|
||
els.status.textContent = currentUiT('filesSkipped', skipped.join(', '));
|
||
} else if (added > 0) {
|
||
els.status.textContent = currentUiT('filesInQueue', audioQueue.length);
|
||
}
|
||
|
||
renderAudioQueue();
|
||
}
|
||
|
||
function renderAudioQueue() {
|
||
if (!els.audioQueueList) return;
|
||
|
||
if (!audioQueue.length) {
|
||
els.audioPrompt.classList.remove('is-hidden');
|
||
els.audioFileInfo.classList.add('is-hidden');
|
||
return;
|
||
}
|
||
|
||
els.audioPrompt.classList.add('is-hidden');
|
||
els.audioFileInfo.classList.remove('is-hidden');
|
||
|
||
els.audioQueueList.innerHTML = audioQueue.map((item, i) => {
|
||
const sizeMB = (item.file.size / 1024 / 1024).toFixed(1);
|
||
const statusIcon = item.status === 'processing' ? '⏳'
|
||
: item.status === 'done' ? '✓'
|
||
: item.status === 'error' ? '✗'
|
||
: `${i + 1}.`;
|
||
const statusClass = `queue-item queue-item--${item.status}`;
|
||
return `<li class="${statusClass}">
|
||
<span class="queue-num">${statusIcon}</span>
|
||
<span class="queue-name">${escapeHtml(item.file.name)}</span>
|
||
<span class="queue-size">${sizeMB} MB</span>
|
||
</li>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderEntityCounts(counts = {}) {
|
||
const entries = Object.entries(counts).filter(([, count]) => Number(count) > 0);
|
||
if (!entries.length) {
|
||
return '<p class="muted">No deterministic sensitive categories detected.</p>';
|
||
}
|
||
return `<ul class="pill-list">${entries.map(([name, count]) => `<li>${escapeHtml(name)} <strong>${Number(count)}</strong></li>`).join('')}</ul>`;
|
||
}
|
||
|
||
function detailList(title, values = []) {
|
||
if (!Array.isArray(values) || !values.length) {
|
||
return '';
|
||
}
|
||
return `<div class="detail-block"><h4>${escapeHtml(title)}</h4><ul>${values.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul></div>`;
|
||
}
|
||
|
||
function renderListish(value) {
|
||
if (Array.isArray(value)) {
|
||
if (!value.length) {
|
||
return '<p>No uncertainty listed.</p>';
|
||
}
|
||
return `<ul>${value.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul>`;
|
||
}
|
||
return `<p>${escapeHtml(value || 'No uncertainty listed.')}</p>`;
|
||
}
|
||
|
||
function sectionHtml(title, content) {
|
||
return `<section class="result-section"><h3>${escapeHtml(title)}</h3>${content}</section>`;
|
||
}
|
||
|
||
function renderTrace(trace) {
|
||
if (!trace.length) {
|
||
els.traceList.innerHTML = `
|
||
<li>
|
||
<span class="trace-status waiting"></span>
|
||
<div><strong>Waiting</strong><p>Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.</p></div>
|
||
</li>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
els.traceList.innerHTML = trace.map((item) => `
|
||
<li>
|
||
<span class="trace-status ${escapeHtml(item.status || 'complete')}"></span>
|
||
<div>
|
||
<strong>${escapeHtml(item.label || 'Step')}</strong>
|
||
<p>${escapeHtml(item.detail || '')}</p>
|
||
</div>
|
||
</li>
|
||
`).join('');
|
||
}
|
||
|
||
function renderHealth(data) {
|
||
const checks = Object.entries(data.checks || {}).map(([name, check]) => ({
|
||
label: name.replaceAll('_', ' '),
|
||
detail: check.detail || '',
|
||
status: check.ok ? 'complete' : 'warning',
|
||
}));
|
||
renderTrace(checks);
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '')
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|