Files
dobetternorge-tools/assets/js/tools.js
T
daveadmin 30915bcb09 Redact: collapsible advanced settings, download TXT/DOCX/copy
- Wrap Mode/Region/Entities/Officials/Output/Exempt/Aliases in a
  <details> toggle so the form opens clean with only engine + input visible
- After redaction: Copy, Download .txt, Download .docx buttons appear
  below the redacted output (all four languages translated)
- New api/redact-download.php: returns plain text or a minimal valid
  DOCX built from scratch with ZipArchive (no external dependencies)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:33:50 +02:00

1729 lines
72 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
activeTool: 'ask',
authenticated: Boolean(window.DBN_TOOLS_AUTHENTICATED),
};
const REDACT_I18N = {
en: {
redactEngine: 'Engine',
redactEngineAzureMini: 'Azure gpt-4o-mini',
redactEngineAzureFull: 'Azure gpt-4o',
redactEngineGpu: 'GPU (cuttlefish)',
redactEngineRegex: 'Regex only',
redactEngineHint: 'Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy. Regex-only is instant and free but finds no names or organisations.',
redactMode: 'Mode',
redactModeStandard: 'Standard',
redactModeStrict: 'Strict',
redactModeHint: 'Standard: regex patterns + LLM scan for names/orgs/places. Strict: also replaces any capitalised two-word phrase as a potential name — more aggressive, may produce false positives.',
redactRegion: 'Region',
redactRegionNordic: 'Nordic',
redactRegionEuropean: 'European',
redactRegionEchr: 'ECHR',
redactRegionGlobal: 'Global',
redactRegionHint: 'Nordic: Norwegian fødselsnummer, phone, email, addresses. European: adds IBAN, SE personnummer, UK NI. ECHR: adds application numbers, DOB phrases. Global: adds US SSN, document numbers.',
redactEntities: 'Redact',
redactEntityNames: 'Names',
redactEntityOrgs: 'Organisations',
redactEntityPlaces: 'Places',
redactEntityDob: 'Dates of birth',
redactOfficials: 'Officials',
redactKeepOfficials: 'Keep official names (judges, experts)',
redactOfficialsHint: 'When checked, judges, expert witnesses and caseworkers keep their names in a labelled tag: [JUDGE: Andersen]. Uncheck to replace all names with generic role tags.',
redactOutput: 'Output',
redactOutputContextual: 'Contextual tags',
redactOutputGeneric: 'Generic tags',
redactOutputPseudo: 'Pseudonyms',
redactOutputHint: 'Contextual: each person gets a role tag so their identity is traceable within the document. Generic: all names become [PERSON]. Pseudonyms: replaced with plausible fake Norwegian values.',
redactExempt: 'Exempt names',
redactExemptAdd: 'Add',
redactExemptHint: 'Names listed here will never be redacted, even if the AI would otherwise remove them — e.g. a judge or expert who must remain identifiable.',
redactExemptPlaceholder: 'Name to keep (e.g. Judge Andersen)',
redactAliases: 'Name aliases',
redactAliasAdd: 'Add',
redactAliasHint: 'Replace a specific name with a custom bracketed label, e.g. "David Jr" → [Junior].',
redactUploadAria: 'File upload',
redactUploadDrop: 'Drop up to 5 files here, or',
redactUploadBrowse: 'browse',
redactUploadHint: 'text extracted in memory, never stored',
redactUploadClear: '× Clear',
redactInputLabel: 'Pasted text',
redactInputPlaceholder: 'Paste text containing names, phone numbers, emails, addresses, or national ID numbers.',
redactRun: 'Run',
redactRunning: 'Redacting…',
redactReadyTitle: 'Ready',
redactReadyDesc: 'Paste text or upload a file, configure redaction options, then run.',
redactAdvancedToggle: 'Advanced settings',
redactDownloadTxt: 'Download .txt',
redactDownloadDocx: 'Download .docx',
redactCopy: 'Copy',
redactCopied: 'Copied!',
},
no: {
redactEngine: 'Motor',
redactEngineAzureMini: 'Azure gpt-4o-mini',
redactEngineAzureFull: 'Azure gpt-4o',
redactEngineGpu: 'GPU (cuttlefish)',
redactEngineRegex: 'Kun regex',
redactEngineHint: 'Azure-motorer bruker BNL Azure-kreditter. GPU kjører lokal LiteLLM-proxy. Kun regex er øyeblikkelig og gratis, men finner ingen navn eller organisasjoner.',
redactMode: 'Modus',
redactModeStandard: 'Standard',
redactModeStrict: 'Strikt',
redactModeHint: 'Standard: regex-mønstre + LLM-skanning for navn/org/steder. Strikt: erstatter også enhver stor-stav-kombinasjon som potensielt navn — mer aggressivt, kan gi falske positiver.',
redactRegion: 'Region',
redactRegionNordic: 'Nordisk',
redactRegionEuropean: 'Europeisk',
redactRegionEchr: 'EMD',
redactRegionGlobal: 'Global',
redactRegionHint: 'Nordisk: norsk fødselsnummer, telefon, e-post, adresser. Europeisk: legger til IBAN, SE personnummer, UK NI. EMD: legger til saksnummer, fødselsdatofraser. Global: legger til US SSN, dokumentnummer.',
redactEntities: 'Rediger',
redactEntityNames: 'Navn',
redactEntityOrgs: 'Organisasjoner',
redactEntityPlaces: 'Steder',
redactEntityDob: 'Fødselsdatoer',
redactOfficials: 'Offisielle',
redactKeepOfficials: 'Behold offisielle navn (dommere, sakkyndige)',
redactOfficialsHint: 'Når avkrysset beholder dommere, sakkyndige og saksbehandlere sine navn i en merket tagg: [DOMMER: Andersen]. Fjern haken for å erstatte alle navn med generiske rolletaggar.',
redactOutput: 'Utdata',
redactOutputContextual: 'Kontekstuelle taggar',
redactOutputGeneric: 'Generiske taggar',
redactOutputPseudo: 'Pseudonymer',
redactOutputHint: 'Kontekstuell: hver person får en rolletagg slik at identiteten kan spores i dokumentet. Generisk: alle navn blir [PERSON]. Pseudonymer: erstattes med troverdige falske norske verdier.',
redactExempt: 'Unntak',
redactExemptAdd: 'Legg til',
redactExemptHint: 'Navn oppført her vil aldri bli redigert, selv om AI ellers ville fjernet dem — f.eks. en dommer eller sakkyndig som må forbli identifiserbar.',
redactExemptPlaceholder: 'Navn som skal beholdes (f.eks. Dommer Andersen)',
redactAliases: 'Navnealiaser',
redactAliasAdd: 'Legg til',
redactAliasHint: 'Erstatt et spesifikt navn med en egendefinert merkelapp, f.eks. «David Jr» → [Junior].',
redactUploadAria: 'Filopplasting',
redactUploadDrop: 'Slipp opptil 5 filer her, eller',
redactUploadBrowse: 'bla',
redactUploadHint: 'tekst hentes i minnet, lagres aldri',
redactUploadClear: '× Tøm',
redactInputLabel: 'Limt inn tekst',
redactInputPlaceholder: 'Lim inn tekst med navn, telefonnummer, e-poster, adresser eller personnummer.',
redactRun: 'Kjør',
redactRunning: 'Redigerer…',
redactReadyTitle: 'Klar',
redactReadyDesc: 'Lim inn tekst eller last opp en fil, konfigurer redigeringsalternativene, og kjør.',
redactAdvancedToggle: 'Avanserte innstillinger',
redactDownloadTxt: 'Last ned .txt',
redactDownloadDocx: 'Last ned .docx',
redactCopy: 'Kopier',
redactCopied: 'Kopiert!',
},
uk: {
redactEngine: 'Рушій',
redactEngineAzureMini: 'Azure gpt-4o-mini',
redactEngineAzureFull: 'Azure gpt-4o',
redactEngineGpu: 'GPU (cuttlefish)',
redactEngineRegex: 'Лише регулярні вирази',
redactEngineHint: 'Рушії Azure використовують кредити BNL Azure. GPU запускає локальний проксі LiteLLM. Лише regex — миттєво і безкоштовно, але не знаходить імен або організацій.',
redactMode: 'Режим',
redactModeStandard: 'Стандартний',
redactModeStrict: 'Суворий',
redactModeHint: 'Стандарт: шаблони regex + LLM-сканування для імен/орг/місць. Суворий: також замінює будь-яку комбінацію слів з великої літери як потенційне ім\'я.',
redactRegion: 'Регіон',
redactRegionNordic: 'Nordisk',
redactRegionEuropean: 'Європейський',
redactRegionEchr: 'ЄСПЛ',
redactRegionGlobal: 'Глобальний',
redactRegionHint: 'Nordisk: норвезький фødselsnummer, телефон, email, адреси. Європейський: додає IBAN, SE personnummer, UK NI. ЄСПЛ: додає номери справ, фрази дати народження. Глобальний: додає US SSN.',
redactEntities: 'Редагувати',
redactEntityNames: 'Імена',
redactEntityOrgs: 'Організації',
redactEntityPlaces: 'Місця',
redactEntityDob: 'Дати народження',
redactOfficials: 'Офіційні особи',
redactKeepOfficials: 'Зберігати офіційні імена (судді, експерти)',
redactOfficialsHint: 'Якщо позначено, судді, експерти та соціальні працівники зберігають свої імена у позначеному тезі: [СУДДЯ: Andersen].',
redactOutput: 'Вивід',
redactOutputContextual: 'Контекстні теги',
redactOutputGeneric: 'Загальні теги',
redactOutputPseudo: 'Псевдоніми',
redactOutputHint: 'Контекстний: кожна особа отримує тег ролі. Загальний: всі імена стають [PERSON]. Псевдоніми: замінюються правдоподібними норвезькими значеннями.',
redactExempt: 'Виключені імена',
redactExemptAdd: 'Додати',
redactExemptHint: 'Імена, перелічені тут, ніколи не будуть відредаговані.',
redactExemptPlaceholder: 'Ім\'я для збереження (напр. суддя Andersen)',
redactAliases: 'Псевдоніми імен',
redactAliasAdd: 'Додати',
redactAliasHint: 'Замініть конкретне ім\'я на власну мітку, напр. «David Jr» → [Junior].',
redactUploadAria: 'Завантаження файлів',
redactUploadDrop: 'Перетягніть до 5 файлів сюди, або',
redactUploadBrowse: 'огляд',
redactUploadHint: 'текст обробляється в пам\'яті, ніколи не зберігається',
redactUploadClear: '× Очистити',
redactInputLabel: 'Вставлений текст',
redactInputPlaceholder: 'Вставте текст з іменами, телефонами, адресами або ідентифікаційними номерами.',
redactRun: 'Запустити',
redactRunning: 'Редагування…',
redactReadyTitle: 'Готово',
redactReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.',
redactAdvancedToggle: 'Розширені налаштування',
redactDownloadTxt: 'Завантажити .txt',
redactDownloadDocx: 'Завантажити .docx',
redactCopy: 'Копіювати',
redactCopied: 'Скопійовано!',
},
pl: {
redactEngine: 'Silnik',
redactEngineAzureMini: 'Azure gpt-4o-mini',
redactEngineAzureFull: 'Azure gpt-4o',
redactEngineGpu: 'GPU (cuttlefish)',
redactEngineRegex: 'Tylko regex',
redactEngineHint: 'Silniki Azure używają kredytów Azure BNL. GPU korzysta z lokalnego proxy LiteLLM. Tylko regex jest natychmiastowy i bezpłatny, ale nie znajdzie imion ani organizacji.',
redactMode: 'Tryb',
redactModeStandard: 'Standardowy',
redactModeStrict: 'Ścisły',
redactModeHint: 'Standardowy: wzorce regex + skanowanie LLM dla imion/org/miejsc. Ścisły: zastępuje też każdą kombinację słów pisanych wielką literą jako potencjalne imię.',
redactRegion: 'Region',
redactRegionNordic: 'Nordycki',
redactRegionEuropean: 'Europejski',
redactRegionEchr: 'ETPC',
redactRegionGlobal: 'Globalny',
redactRegionHint: 'Nordycki: norweski fødselsnummer, telefon, email, adresy. Europejski: dodaje IBAN, SE personnummer, UK NI. ETPC: dodaje numery spraw, frazy daty urodzenia. Globalny: dodaje US SSN.',
redactEntities: 'Redaguj',
redactEntityNames: 'Imiona',
redactEntityOrgs: 'Organizacje',
redactEntityPlaces: 'Miejsca',
redactEntityDob: 'Daty urodzenia',
redactOfficials: 'Urzędnicy',
redactKeepOfficials: 'Zachowaj oficjalne nazwy (sędziowie, eksperci)',
redactOfficialsHint: 'Gdy zaznaczone, sędziowie, biegli i pracownicy socjalni zachowują swoje nazwiska w oznaczonym tagu: [SĘDZIA: Andersen].',
redactOutput: 'Wyjście',
redactOutputContextual: 'Tagi kontekstowe',
redactOutputGeneric: 'Tagi ogólne',
redactOutputPseudo: 'Pseudonimy',
redactOutputHint: 'Kontekstowe: każda osoba otrzymuje tag roli. Ogólne: wszystkie imiona stają się [PERSON]. Pseudonimy: zastąpione wiarygodnymi fałszywymi wartościami norweskimi.',
redactExempt: 'Zwolnione nazwy',
redactExemptAdd: 'Dodaj',
redactExemptHint: 'Nazwy tu wpisane nigdy nie zostaną zredagowane.',
redactExemptPlaceholder: 'Nazwa do zachowania (np. Sędzia Andersen)',
redactAliases: 'Aliasy nazw',
redactAliasAdd: 'Dodaj',
redactAliasHint: 'Zastąp konkretną nazwę własną etykietą, np. «David Jr» → [Junior].',
redactUploadAria: 'Przesyłanie pliku',
redactUploadDrop: 'Upuść do 5 plików tutaj lub',
redactUploadBrowse: 'przeglądaj',
redactUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany',
redactUploadClear: '× Wyczyść',
redactInputLabel: 'Wklejony tekst',
redactInputPlaceholder: 'Wklej tekst zawierający imiona, numery telefonów, adresy lub numery identyfikacyjne.',
redactRun: 'Uruchom',
redactRunning: 'Redagowanie…',
redactReadyTitle: 'Gotowe',
redactReadyDesc: 'Wklej tekst lub wgraj plik, skonfiguruj opcje redakcji, uruchom.',
redactAdvancedToggle: 'Ustawienia zaawansowane',
redactDownloadTxt: 'Pobierz .txt',
redactDownloadDocx: 'Pobierz .docx',
redactCopy: 'Kopiuj',
redactCopied: 'Skopiowano!',
},
};
let lastTimelineEvents = [];
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
let lastTranscriptData = null;
let lastRedactedText = 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 25MB.',
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 200MB 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)',
beamSizeHint: 'Controls search breadth — higher values improve accuracy but take longer. 5 is recommended for legal recordings.',
vadFilter: 'VAD filter',
vadFilterLabel: 'Remove silence',
vadFilterHint: 'Voice Activity Detection — skips silent passages before transcribing. Speeds up processing and prevents the model hallucinating on 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 13 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 25MB.',
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 200MB 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)',
beamSizeHint: 'Styrer søkebredde — høyere verdier gir bedre nøyaktighet men tar lengre tid. 5 anbefales for juridiske opptak.',
vadFilter: 'VAD-filter',
vadFilterLabel: 'Fjern stillhet',
vadFilterHint: 'Taleaktivitetsdeteksjon — hopper over stille partier før transkripsjon. Raskere behandling og forhindrer hallusinasjon på 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 13 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: '(найкращий)',
beamSizeHint: 'Ширина пошуку — більше значення підвищує точність, але займає більше часу. 5 рекомендовано для юридичних записів.',
vadFilter: 'VAD-фільтр',
vadFilterLabel: 'Видалити тишу',
vadFilterHint: 'Виявлення мовної активності — пропускає тихі ділянки перед транскрипцією. Прискорює обробку та запобігає галюцинаціям на тиші.',
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 25MB.',
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 200MB 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)',
beamSizeHint: 'Kontroluje szerokość wyszukiwania — wyższe wartości poprawiają dokładność, ale wydłużają czas. 5 zalecane dla nagrań prawnych.',
vadFilter: 'Filtr VAD',
vadFilterLabel: 'Usuń ciszę',
vadFilterHint: 'Wykrywanie aktywności głosowej — pomija ciche fragmenty przed transkrypcją. Przyspiesza przetwarzanie i zapobiega halucynacjom na ciszy.',
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ą 13 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);
});
}
function currentRedactT(key) {
const t = REDACT_I18N[uiLang] || REDACT_I18N.en;
return (key in t) ? t[key] : (REDACT_I18N.en[key] ?? key);
}
function applyRedactI18n(lang) {
uiLang = lang;
localStorage.setItem('dbn-ui-lang', lang);
document.querySelectorAll('[data-i18n]').forEach((el) => {
const text = currentRedactT(el.dataset.i18n);
if (text != null) el.textContent = text;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
const text = currentRedactT(el.dataset.i18nPlaceholder);
if (text != null) el.placeholder = text;
});
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
const text = currentRedactT(el.dataset.i18nAria);
if (text != null) el.setAttribute('aria-label', text);
});
document.querySelectorAll('#redactLangSwitcher .lang-btn').forEach((btn) => {
btn.classList.toggle('is-active', btn.dataset.lang === lang);
});
}
function currentRedactEngine() {
return document.querySelector('input[name="redactEngine"]:checked')?.value || 'azure_mini';
}
function currentOutputFormat() {
return document.querySelector('input[name="outputFormat"]:checked')?.value || 'contextual';
}
function currentKeepOfficials() {
return document.getElementById('keepOfficialsCheck')?.checked ?? false;
}
function currentRedactTypes() {
return {
names: document.getElementById('redactNames')?.checked ?? true,
orgs: document.getElementById('redactOrgs')?.checked ?? true,
places: document.getElementById('redactPlaces')?.checked ?? true,
dob: document.getElementById('redactDob')?.checked ?? true,
};
}
function setupRedactControls() {
const switcher = document.getElementById('redactLangSwitcher');
if (!switcher) return;
switcher.querySelectorAll('.lang-btn').forEach((btn) => {
btn.addEventListener('click', () => applyRedactI18n(btn.dataset.lang));
});
applyRedactI18n(uiLang);
}
function setupExemptNames() {
const addBtn = document.getElementById('addExemptRow');
const rows = document.getElementById('exemptRows');
if (!addBtn || !rows) return;
addBtn.addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'exempt-row';
row.innerHTML = [
`<input type="text" class="exempt-name-input" placeholder="${escapeHtml(currentRedactT('redactExemptPlaceholder'))}" maxlength="100">`,
'<button type="button" class="alias-remove" aria-label="Remove exempt name">×</button>',
].join('');
rows.appendChild(row);
row.querySelector('.exempt-name-input').focus();
});
rows.addEventListener('click', (e) => {
const btn = e.target.closest('.alias-remove');
if (btn) btn.closest('.exempt-row').remove();
});
}
function getExemptNames() {
return Array.from(document.querySelectorAll('#exemptRows .exempt-name-input'))
.map((el) => el.value.trim())
.filter(Boolean);
}
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();
setupRedactControls();
setupExemptNames();
// Wire transcribe lang switcher (only present on transcribe page)
document.querySelectorAll('#uiLangSwitcher .lang-btn').forEach((btn) => {
btn.addEventListener('click', () => applyTranscribeI18n(btn.dataset.lang));
});
if (document.getElementById('uiLangSwitcher')) {
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();
if (e.target.closest('#rdlCopy')) copyRedactedText();
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
if (e.target.closest('#rdlDocx')) downloadRedactedDocx();
});
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();
payload.engine = currentRedactEngine();
payload.output_format = currentOutputFormat();
payload.keep_officials = currentKeepOfficials();
payload.exempt_names = getExemptNames();
payload.redact_types = currentRedactTypes();
}
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 128000 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;
if (state.activeTool === 'transcribe') {
button.textContent = isBusy ? currentUiT('running') : currentUiT('run');
} else if (state.activeTool === 'redact') {
button.textContent = isBusy ? currentRedactT('redactRunning') : currentRedactT('redactRun');
} else {
button.textContent = isBusy ? 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') {
lastRedactedText = data.redacted_text || '';
const t = (k) => currentRedactT(k) || k;
const dlRow = `<div class="redact-downloads">
<button type="button" class="redact-dl-btn" id="rdlCopy">${t('redactCopy')}</button>
<button type="button" class="redact-dl-btn" id="rdlTxt">${t('redactDownloadTxt')}</button>
<button type="button" class="redact-dl-btn" id="rdlDocx">${t('redactDownloadDocx')}</button>
</div>`;
return `<pre class="redacted-output">${escapeHtml(lastRedactedText)}</pre>${renderEntityCounts(data.entity_counts)}${dlRow}`;
}
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' && !window.DBN_AZURE_SPEECH_CONFIGURED) {
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');
}
async function copyRedactedText() {
if (!lastRedactedText) return;
const btn = document.getElementById('rdlCopy');
await navigator.clipboard.writeText(lastRedactedText);
if (btn) {
const orig = btn.textContent;
btn.textContent = currentRedactT('redactCopied') || 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 1800);
}
}
function downloadRedactedTxt() {
if (!lastRedactedText) return;
downloadBlob(new Blob([lastRedactedText], { type: 'text/plain' }), 'redacted.txt');
}
async function downloadRedactedDocx() {
if (!lastRedactedText) return;
const btn = document.getElementById('rdlDocx');
if (btn) btn.disabled = true;
try {
const resp = await fetch('/api/redact-download.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: lastRedactedText, format: 'docx' }),
});
if (!resp.ok) throw new Error('Download failed');
const blob = await resp.blob();
downloadBlob(blob, 'redacted.docx');
} catch (e) {
alert(e.message);
} finally {
if (btn) btn.disabled = false;
}
}
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');
// Hide azure key row if server has a pre-configured key
const azureNeedsKey = engine === 'azure' && !window.DBN_AZURE_SPEECH_CONFIGURED;
document.getElementById('azureKeyControl')?.classList.toggle('is-hidden', !azureNeedsKey);
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}