2942 lines
129 KiB
JavaScript
2942 lines
129 KiB
JavaScript
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',
|
||
redactEngineHint: 'gpt-4o-mini: 1 credit — fast, handles most documents well. gpt-4o: 2 credits — higher accuracy for complex or multi-person cases.',
|
||
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',
|
||
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 one file 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!',
|
||
docPickerBtn: 'Select from My Docs',
|
||
},
|
||
no: {
|
||
redactEngine: 'Motor',
|
||
redactEngineAzureMini: 'Azure gpt-4o-mini',
|
||
redactEngineAzureFull: 'Azure gpt-4o',
|
||
redactEngineHint: 'gpt-4o-mini: 1 kreditt — rask, håndterer de fleste dokumenter godt. gpt-4o: 2 kreditter — høyere nøyaktighet for komplekse eller flerpersonssaker.',
|
||
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: 'Datoer',
|
||
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 én fil 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!',
|
||
docPickerBtn: 'Velg fra Mine dokumenter',
|
||
},
|
||
uk: {
|
||
redactEngine: 'Рушій',
|
||
redactEngineAzureMini: 'Azure gpt-4o-mini',
|
||
redactEngineAzureFull: 'Azure gpt-4o',
|
||
redactEngineHint: 'gpt-4o-mini: 1 кредит — швидко, добре обробляє більшість документів. gpt-4o: 2 кредити — вища точність для складних або багатоособових справ.',
|
||
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: 'Перетягніть один файл сюди, або',
|
||
redactUploadBrowse: 'огляд',
|
||
redactUploadHint: 'текст обробляється в пам\'яті, ніколи не зберігається',
|
||
redactUploadClear: '× Очистити',
|
||
redactInputLabel: 'Вставлений текст',
|
||
redactInputPlaceholder: 'Вставте текст з іменами, телефонами, адресами або ідентифікаційними номерами.',
|
||
redactRun: 'Запустити',
|
||
redactRunning: 'Редагування…',
|
||
redactReadyTitle: 'Готово',
|
||
redactReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.',
|
||
redactAdvancedToggle: 'Розширені налаштування',
|
||
redactDownloadTxt: 'Завантажити .txt',
|
||
redactDownloadDocx: 'Завантажити .docx',
|
||
redactCopy: 'Копіювати',
|
||
redactCopied: 'Скопійовано!',
|
||
docPickerBtn: 'Вибрати з Моїх документів',
|
||
},
|
||
pl: {
|
||
redactEngine: 'Silnik',
|
||
redactEngineAzureMini: 'Azure gpt-4o-mini',
|
||
redactEngineAzureFull: 'Azure gpt-4o',
|
||
redactEngineHint: 'gpt-4o-mini: 1 kredyt — szybko, dobrze radzi sobie z większością dokumentów. gpt-4o: 2 kredyty — wyższa dokładność dla złożonych lub wieloosobowych spraw.',
|
||
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',
|
||
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ść jeden plik 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!',
|
||
docPickerBtn: 'Wybierz z Moich dokumentów',
|
||
},
|
||
};
|
||
|
||
const TIMELINE_I18N = {
|
||
en: {
|
||
timelineEngine: 'Engine',
|
||
timelineEngineAzureMini: 'Azure gpt-4o-mini',
|
||
timelineEngineAzureFull: 'Azure gpt-4o',
|
||
timelineEngineHint: 'gpt-4o-mini: 1 credit — fast, handles most timelines well. gpt-4o: 2 credits — higher accuracy for complex multi-actor cases.',
|
||
timelineAdvancedToggle: 'Advanced settings',
|
||
timelineFocus: 'Focus',
|
||
timelineFocusAll: 'All events',
|
||
timelineFocusDeadlines: 'Legal deadlines',
|
||
timelineFocusHearings: 'Court hearings',
|
||
timelineFocusCps: 'CPS milestones',
|
||
timelineFocusHint: 'All events: every temporal reference. Legal deadlines: filing dates, appeal windows, statutory limits. Court hearings: tribunal and mediation sessions. CPS milestones: Barnevernet interventions and Fylkesnemnda proceedings.',
|
||
timelineConfidence: 'Confidence',
|
||
timelineConfidenceAll: 'Show all events',
|
||
timelineConfidenceHighMed: 'Hide low-confidence',
|
||
timelineConfidenceHint: 'Show all: includes uncertain events (shown in grey). Hide low-confidence: only returns events the model is reasonably sure of.',
|
||
timelineDates: 'Date types',
|
||
timelineIncludeRelative: 'Include relative / recurring dates',
|
||
timelineDatesHint: 'When checked, relative references ("three weeks later", "every Monday") are included. Uncheck to return only exact calendar dates.',
|
||
timelineUploadAria: 'File upload',
|
||
timelineUploadDrop: 'Drop a file here, or',
|
||
timelineUploadBrowse: 'browse',
|
||
timelineUploadHint: 'text extracted in memory, never stored',
|
||
timelineUploadClear: '× Clear',
|
||
timelineInputLabel: 'Pasted text',
|
||
timelineInputPlaceholder: 'Paste case notes, court decisions, or correspondence containing dates and events.',
|
||
timelineRun: 'Run',
|
||
timelineRunning: 'Building timeline…',
|
||
timelineReadyTitle: 'Ready',
|
||
timelineReadyDesc: 'Paste text or upload a file, configure options, then run.',
|
||
timelineBackground: 'Background events',
|
||
timelineIncludeBackground: 'Include narrative / background dates',
|
||
timelineBackgroundHint: 'When checked, historical context dates are included (e.g. "born 30.07.2015", "met around 2011/2012"). Uncheck to extract only operational events and deadlines.',
|
||
sortDocOrder: 'Document order',
|
||
sortChronological: 'Chronological',
|
||
timelineExportCsv: 'Download CSV',
|
||
timelineNotesLabel: 'Context notes',
|
||
timelineNotesPlaceholder: 'Add any clarifications to guide the AI — e.g. "All dates are 2024", "Focus on the mother\'s actions", "D refers to the defendant throughout".',
|
||
timelineNotesHint: 'These notes are included in the prompt to help the model interpret ambiguous dates, actors, or abbreviations. Not stored.',
|
||
docPickerBtn: 'Select from My Docs',
|
||
},
|
||
no: {
|
||
timelineEngine: 'Motor',
|
||
timelineEngineAzureMini: 'Azure gpt-4o-mini',
|
||
timelineEngineAzureFull: 'Azure gpt-4o',
|
||
timelineEngineHint: 'gpt-4o-mini: 1 kreditt — rask, håndterer de fleste tidslinjer godt. gpt-4o: 2 kreditter — høyere nøyaktighet for komplekse saker med mange aktører.',
|
||
timelineAdvancedToggle: 'Avanserte innstillinger',
|
||
timelineFocus: 'Fokus',
|
||
timelineFocusAll: 'Alle hendelser',
|
||
timelineFocusDeadlines: 'Juridiske frister',
|
||
timelineFocusHearings: 'Rettsmøter',
|
||
timelineFocusCps: 'Barnevernet-milepæler',
|
||
timelineFocusHint: 'Alle hendelser: alle temporale referanser. Juridiske frister: innleveringsfrister, ankefrister, lovpålagte frister. Rettsmøter: domstol- og meklingsmøter. Barnevernet: akuttvedtak og Fylkesnemnda-saker.',
|
||
timelineConfidence: 'Sikkerhet',
|
||
timelineConfidenceAll: 'Vis alle hendelser',
|
||
timelineConfidenceHighMed: 'Skjul usikre hendelser',
|
||
timelineConfidenceHint: 'Vis alle: inkluderer usikre hendelser (vist i grått). Skjul usikre: returnerer bare hendelser modellen er rimelig sikker på.',
|
||
timelineDates: 'Datotyper',
|
||
timelineIncludeRelative: 'Inkluder relative / gjentakende datoer',
|
||
timelineDatesHint: 'Når avkrysset inkluderes relative referanser ("tre uker senere", "hver mandag"). Fjern haken for å returnere kun eksakte kalenderdatoer.',
|
||
timelineUploadAria: 'Filopplasting',
|
||
timelineUploadDrop: 'Slipp en fil her, eller',
|
||
timelineUploadBrowse: 'bla',
|
||
timelineUploadHint: 'tekst hentes i minnet, lagres aldri',
|
||
timelineUploadClear: '× Tøm',
|
||
timelineInputLabel: 'Limt inn tekst',
|
||
timelineInputPlaceholder: 'Lim inn saksdokumenter, rettsavgjørelser eller korrespondanse med datoer og hendelser.',
|
||
timelineRun: 'Kjør',
|
||
timelineRunning: 'Bygger tidslinje…',
|
||
timelineReadyTitle: 'Klar',
|
||
timelineReadyDesc: 'Lim inn tekst eller last opp en fil, konfigurer alternativene, og kjør.',
|
||
timelineBackground: 'Bakgrunnshendelser',
|
||
timelineIncludeBackground: 'Inkluder narrative / bakgrunnsdatoer',
|
||
timelineBackgroundHint: 'Når avkrysset inkluderes historiske kontekstdatoer (f.eks. "født 30.07.2015", "møttes rundt 2011/2012"). Fjern haken for å hente kun operasjonelle hendelser og frister.',
|
||
sortDocOrder: 'Dokumentrekkefølge',
|
||
sortChronological: 'Kronologisk',
|
||
timelineExportCsv: 'Last ned CSV',
|
||
timelineNotesLabel: 'Kontekstnotes',
|
||
timelineNotesPlaceholder: 'Legg til avklaringer for å veilede AI-en — f.eks. "Alle datoer er 2024", "Fokuser på morens handlinger", "D refererer til saksøkte gjennom hele dokumentet".',
|
||
timelineNotesHint: 'Disse notatene inkluderes i ledeteksten for å hjelpe modellen med å tolke uklare datoer, aktører eller forkortelser. Lagres ikke.',
|
||
docPickerBtn: 'Velg fra Mine dokumenter',
|
||
},
|
||
uk: {
|
||
timelineEngine: 'Рушій',
|
||
timelineEngineAzureMini: 'Azure gpt-4o-mini',
|
||
timelineEngineAzureFull: 'Azure gpt-4o',
|
||
timelineEngineHint: 'gpt-4o-mini: 1 кредит — швидко, добре справляється з більшістю хронологій. gpt-4o: 2 кредити — вища точність для складних справ з багатьма учасниками.',
|
||
timelineAdvancedToggle: 'Розширені налаштування',
|
||
timelineFocus: 'Фокус',
|
||
timelineFocusAll: 'Всі події',
|
||
timelineFocusDeadlines: 'Юридичні терміни',
|
||
timelineFocusHearings: 'Судові засідання',
|
||
timelineFocusCps: 'Вехи органів опіки',
|
||
timelineFocusHint: 'Всі події: кожне часове посилання. Юридичні терміни: дати подання, вікна апеляції. Судові засідання: трибунали та медіації. Опіка: втручання Barnevernet та провадження Fylkesnemnda.',
|
||
timelineConfidence: 'Достовірність',
|
||
timelineConfidenceAll: 'Показати всі події',
|
||
timelineConfidenceHighMed: 'Приховати малодостовірні',
|
||
timelineConfidenceHint: 'Показати всі: включає непевні події (сірим). Приховати малодостовірні: повертає лише впевнено визначені події.',
|
||
timelineDates: 'Типи дат',
|
||
timelineIncludeRelative: 'Включити відносні / повторювані дати',
|
||
timelineDatesHint: 'Якщо позначено, відносні посилання ("три тижні потому", "щопонеділка") включаються. Зніміть, щоб повертати лише точні календарні дати.',
|
||
timelineUploadAria: 'Завантаження файлів',
|
||
timelineUploadDrop: 'Перетягніть один файл сюди, або',
|
||
timelineUploadBrowse: 'огляд',
|
||
timelineUploadHint: 'текст обробляється в памʼяті, ніколи не зберігається',
|
||
timelineUploadClear: '× Очистити',
|
||
timelineInputLabel: 'Вставлений текст',
|
||
timelineInputPlaceholder: 'Вставте нотатки справи, судові рішення або кореспонденцію з датами та подіями.',
|
||
timelineRun: 'Запустити',
|
||
timelineRunning: 'Будую хронологію…',
|
||
timelineReadyTitle: 'Готово',
|
||
timelineReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.',
|
||
timelineBackground: 'Фонові події',
|
||
timelineIncludeBackground: 'Включити наративні / фонові дати',
|
||
timelineBackgroundHint: 'Якщо позначено, включаються дати з контексту (напр. "народився 30.07.2015", "зустрілися близько 2011/2012"). Зніміть, щоб витягувати лише операційні події.',
|
||
sortDocOrder: 'Порядок документа',
|
||
sortChronological: 'Хронологічний',
|
||
timelineExportCsv: 'Завантажити CSV',
|
||
timelineNotesLabel: 'Контекстні нотатки',
|
||
timelineNotesPlaceholder: 'Додайте пояснення для ШІ — напр. "Усі дати відносяться до 2024 року", "Зосередьтесь на діях матері", "D — відповідач по всьому документу".',
|
||
timelineNotesHint: 'Ці нотатки включаються до запиту, щоб допомогти моделі інтерпретувати неоднозначні дати, учасників або скорочення. Не зберігаються.',
|
||
docPickerBtn: 'Вибрати з Моїх документів',
|
||
},
|
||
pl: {
|
||
timelineEngine: 'Silnik',
|
||
timelineEngineAzureMini: 'Azure gpt-4o-mini',
|
||
timelineEngineAzureFull: 'Azure gpt-4o',
|
||
timelineEngineHint: 'gpt-4o-mini: 1 kredyt — szybki, sprawdza się w większości osi czasu. gpt-4o: 2 kredyty — wyższa dokładność dla złożonych spraw z wieloma uczestnikami.',
|
||
timelineAdvancedToggle: 'Ustawienia zaawansowane',
|
||
timelineFocus: 'Fokus',
|
||
timelineFocusAll: 'Wszystkie zdarzenia',
|
||
timelineFocusDeadlines: 'Terminy prawne',
|
||
timelineFocusHearings: 'Rozprawy sądowe',
|
||
timelineFocusCps: 'Kamienie milowe OPS',
|
||
timelineFocusHint: 'Wszystkie: każde odniesienie czasowe. Terminy: daty złożenia, okna apelacyjne. Rozprawy: trybunały i mediacje. OPS: interwencje Barnevernet i postępowania Fylkesnemnda.',
|
||
timelineConfidence: 'Pewność',
|
||
timelineConfidenceAll: 'Pokaż wszystkie zdarzenia',
|
||
timelineConfidenceHighMed: 'Ukryj mało pewne',
|
||
timelineConfidenceHint: 'Pokaż wszystkie: zawiera niepewne zdarzenia (szarym). Ukryj mało pewne: zwraca tylko zdarzenia o rozsądnej pewności.',
|
||
timelineDates: 'Typy dat',
|
||
timelineIncludeRelative: 'Uwzględnij daty względne / cykliczne',
|
||
timelineDatesHint: 'Gdy zaznaczone, odniesienia względne ("trzy tygodnie później", "co poniedziałek") są uwzględniane. Odznacz, aby zwracać tylko dokładne daty kalendarzowe.',
|
||
timelineUploadAria: 'Przesyłanie pliku',
|
||
timelineUploadDrop: 'Upuść jeden plik tutaj lub',
|
||
timelineUploadBrowse: 'przeglądaj',
|
||
timelineUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany',
|
||
timelineUploadClear: '× Wyczyść',
|
||
timelineInputLabel: 'Wklejony tekst',
|
||
timelineInputPlaceholder: 'Wklej notatki sprawy, decyzje sądowe lub korespondencję z datami i zdarzeniami.',
|
||
timelineRun: 'Uruchom',
|
||
timelineRunning: 'Tworzę oś czasu…',
|
||
timelineReadyTitle: 'Gotowe',
|
||
timelineReadyDesc: 'Wklej tekst lub wgraj plik, skonfiguruj opcje, uruchom.',
|
||
timelineBackground: 'Zdarzenia w tle',
|
||
timelineIncludeBackground: 'Uwzględnij daty narracyjne / kontekstowe',
|
||
timelineBackgroundHint: 'Gdy zaznaczone, daty z kontekstu są uwzględniane (np. "urodzony 30.07.2015", "poznali się około 2011/2012"). Odznacz, aby wyodrębniać tylko zdarzenia operacyjne.',
|
||
sortDocOrder: 'Kolejność dokumentu',
|
||
sortChronological: 'Chronologicznie',
|
||
timelineExportCsv: 'Pobierz CSV',
|
||
timelineNotesLabel: 'Notatki kontekstowe',
|
||
timelineNotesPlaceholder: 'Dodaj wyjaśnienia dla AI — np. "Wszystkie daty dotyczą 2024", "Skup się na działaniach matki", "D odnosi się do pozwanego w całym dokumencie".',
|
||
timelineNotesHint: 'Te notatki są dołączane do zapytania, aby pomóc modelowi interpretować niejednoznaczne daty, uczestników lub skróty. Nie są przechowywane.',
|
||
docPickerBtn: 'Wybierz z Moich dokumentów',
|
||
},
|
||
};
|
||
|
||
let lastTimelineEvents = [];
|
||
let lastTimelineEventsOriginal = [];
|
||
let lastTimelineWhatWeFound = '';
|
||
let lastTimelineInputDateHintCount = null;
|
||
let activeActorFilters = new Set();
|
||
let timelineSearchTerm = '';
|
||
let showSources = true;
|
||
let timelineSortMode = 'doc';
|
||
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
|
||
let lastTranscriptData = null;
|
||
let lastRedactedText = null;
|
||
let lastOriginalText = '';
|
||
let lastRedactPayload = null;
|
||
let lastRunEngine = null;
|
||
let lastToolPayload = 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 = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
|
||
|
||
function syncOutputLanguage(lang) {
|
||
const normalized = ['en', 'no', 'uk', 'pl'].includes(lang) ? lang : 'en';
|
||
document.querySelectorAll('input[name="language"]').forEach((input) => {
|
||
if (input.value === normalized) input.checked = true;
|
||
});
|
||
}
|
||
|
||
const TRANSCRIBE_I18N = {
|
||
en: {
|
||
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',
|
||
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.',
|
||
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) => `${clip} — uploading`,
|
||
traceUploadDetail: () => 'Sending to transcription service…',
|
||
traceProcessingLabel: (clip) => `${clip} — transcribing`,
|
||
traceProcessingDetail: () => 'Processing audio. Large files may take 1–3 minutes.',
|
||
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.`; },
|
||
advancedOptions: 'Advanced options',
|
||
task: 'Task',
|
||
taskTranscribe: 'Transcribe',
|
||
taskTranslate: 'Translate to English',
|
||
vadFilter: 'VAD filter',
|
||
vadFilterLabel: 'Remove silence / noise',
|
||
vadFilterHint: 'Improves accuracy on recordings with long pauses.',
|
||
whisperModel: 'Whisper model',
|
||
whisperModelHint: 'Used when Azure/GCP unavailable. large-v3 is the default.',
|
||
postModel: 'AI cleanup',
|
||
postModelNone: 'None',
|
||
postModelMini: 'GPT-4o Mini',
|
||
postModelFull: 'GPT-4o',
|
||
postModelHint: 'Fixes errors, punctuation, and domain terms after transcription.',
|
||
audioPickerBtn: 'Select from My Audio',
|
||
},
|
||
no: {
|
||
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ø',
|
||
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.',
|
||
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) => `${clip} — laster opp`,
|
||
traceUploadDetail: () => 'Sender til transkripsjonsleverandør…',
|
||
traceProcessingLabel: (clip) => `${clip} — transkriberer`,
|
||
traceProcessingDetail: () => 'Behandler lyden. Store filer tar 1–3 minutter.', 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.`; },
|
||
advancedOptions: 'Avanserte valg',
|
||
task: 'Oppgave',
|
||
taskTranscribe: 'Transkriber',
|
||
taskTranslate: 'Oversett til engelsk',
|
||
vadFilter: 'VAD-filter',
|
||
vadFilterLabel: 'Fjern stillhet / støy',
|
||
vadFilterHint: 'Forbedrer nøyaktigheten ved opptak med lange pauser.',
|
||
whisperModel: 'Whisper-modell',
|
||
whisperModelHint: 'Brukes når Azure/GCP ikke er tilgjengelig. large-v3 er standard.',
|
||
postModel: 'AI-opprydding',
|
||
postModelNone: 'Ingen',
|
||
postModelMini: 'GPT-4o Mini',
|
||
postModelFull: 'GPT-4o',
|
||
postModelHint: 'Retter feil, tegnsetting og fagtermer etter transkripsjon.',
|
||
audioPickerBtn: 'Velg fra Mine lydfiler',
|
||
},
|
||
uk: {
|
||
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: '× Очистити чергу',
|
||
run: 'Запустити',
|
||
running: 'Транскрибування…',
|
||
runningOther: 'Виконання…',
|
||
readyTitle: 'Готово',
|
||
readyDesc: 'Виберіть інструмент, запустіть запит — результат з\'явиться тут.',
|
||
noFileSelected: 'Виберіть хоч б один аудіофайл перед транскрибуванням.',
|
||
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) => `${clip} — завантаження`,
|
||
traceUploadDetail: () => 'Відправка до сервісу транскрипції…',
|
||
traceProcessingLabel: (clip) => `${clip} — транскрибування`,
|
||
traceProcessingDetail: () => 'Обробка аудіо. Великі файли займають 1–3 хвилини.', traceStillLabel: (clip) => `${clip} — ще обробляється…`,
|
||
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Минуло ${m} хв ${s} с — обробка.` : `Минуло ${e} с — обробка.`; },
|
||
advancedOptions: 'Розширені параметри',
|
||
task: 'Завдання',
|
||
taskTranscribe: 'Транскрибувати',
|
||
taskTranslate: 'Перекласти на англійську',
|
||
vadFilter: 'VAD-фільтр',
|
||
vadFilterLabel: 'Видалити тишу / шум',
|
||
vadFilterHint: 'Покращує точність для записів з довгими паузами.',
|
||
whisperModel: 'Модель Whisper',
|
||
whisperModelHint: 'Використовується, якщо Azure/GCP недоступні. large-v3 за замовчуванням.',
|
||
postModel: 'AI-очищення',
|
||
postModelNone: 'Без',
|
||
postModelMini: 'GPT-4o Mini',
|
||
postModelFull: 'GPT-4o',
|
||
postModelHint: 'Виправляє помилки, пунктуацію та терміни після транскрипції.',
|
||
audioPickerBtn: 'Вибрати з Мого аудіо',
|
||
},
|
||
pl: {
|
||
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ę',
|
||
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ą.',
|
||
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) => `${clip} — przesyłanie`,
|
||
traceUploadDetail: () => 'Wysyłanie do serwisu transkrypcji…',
|
||
traceProcessingLabel: (clip) => `${clip} — transkrybowanie`,
|
||
traceProcessingDetail: () => 'Przetwarzanie audio. Duże pliki zajmują 1–3 minuty.', 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.`; },
|
||
advancedOptions: 'Opcje zaawansowane',
|
||
task: 'Zadanie',
|
||
taskTranscribe: 'Transkrypcja',
|
||
taskTranslate: 'Przetłumacz na angielski',
|
||
vadFilter: 'Filtr VAD',
|
||
vadFilterLabel: 'Usuń ciszę / szum',
|
||
vadFilterHint: 'Poprawia dokładność nagrań z długimi przerwami.',
|
||
whisperModel: 'Model Whisper',
|
||
whisperModelHint: 'Używany gdy Azure/GCP niedostępne. large-v3 jest domyślny.',
|
||
postModel: 'Korekta AI',
|
||
postModelNone: 'Brak',
|
||
postModelMini: 'GPT-4o Mini',
|
||
postModelFull: 'GPT-4o',
|
||
postModelHint: 'Poprawia błędy, interpunkcję i terminy po transkrypcji.',
|
||
audioPickerBtn: 'Wybierz z Mojego audio',
|
||
},
|
||
};
|
||
|
||
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);
|
||
syncOutputLanguage(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);
|
||
syncOutputLanguage(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 currentTimelineT(key) {
|
||
const t = TIMELINE_I18N[uiLang] || TIMELINE_I18N.en;
|
||
return (key in t) ? t[key] : (TIMELINE_I18N.en[key] ?? key);
|
||
}
|
||
|
||
function applyTimelineI18n(lang) {
|
||
uiLang = lang;
|
||
localStorage.setItem('dbn-ui-lang', lang);
|
||
syncOutputLanguage(lang);
|
||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||
const text = currentTimelineT(el.dataset.i18n);
|
||
if (text != null) el.textContent = text;
|
||
});
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
|
||
const text = currentTimelineT(el.dataset.i18nPlaceholder);
|
||
if (text != null) el.placeholder = text;
|
||
});
|
||
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
|
||
const text = currentTimelineT(el.dataset.i18nAria);
|
||
if (text != null) el.setAttribute('aria-label', text);
|
||
});
|
||
document.querySelectorAll('#timelineLangSwitcher .lang-btn').forEach((btn) => {
|
||
btn.classList.toggle('is-active', btn.dataset.lang === lang);
|
||
});
|
||
}
|
||
|
||
function currentTimelineEngine() {
|
||
return document.querySelector('input[name="timelineEngine"]:checked')?.value || 'azure_mini';
|
||
}
|
||
|
||
function timelineEngineLabel(engine) {
|
||
return {
|
||
nova_lite: 'Quick',
|
||
azure_mini: 'Standard',
|
||
azure_full: 'Deep',
|
||
}[engine] || 'Timeline';
|
||
}
|
||
|
||
function timelineClientRoute(engine, charCount) {
|
||
let effective = engine;
|
||
if (charCount > 55000) effective = 'azure_full';
|
||
else if (charCount > 25000 && effective === 'nova_lite') effective = 'azure_mini';
|
||
return { effective, upgraded: effective !== engine };
|
||
}
|
||
|
||
function currentTimelineFocus() {
|
||
return document.querySelector('input[name="timelineFocus"]:checked')?.value || 'all';
|
||
}
|
||
|
||
function currentConfidenceFilter() {
|
||
return document.querySelector('input[name="confidenceFilter"]:checked')?.value || 'all';
|
||
}
|
||
|
||
function currentIncludeRelative() {
|
||
return document.getElementById('includeRelativeCheck')?.checked ?? true;
|
||
}
|
||
|
||
function currentIncludeBackground() {
|
||
return document.getElementById('includeBackgroundCheck')?.checked ?? true;
|
||
}
|
||
|
||
function sortChronological(events) {
|
||
const isIso = (d) => /^\d{4}-\d{2}/.test(d);
|
||
return [...events].sort((a, b) => {
|
||
const da = a.date || '', db = b.date || '';
|
||
if (isIso(da) && isIso(db)) return da.localeCompare(db);
|
||
if (isIso(da)) return -1;
|
||
if (isIso(db)) return 1;
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
function setupTimelineControls() {
|
||
const switcher = document.getElementById('timelineLangSwitcher');
|
||
if (!switcher) return;
|
||
switcher.querySelectorAll('.lang-btn').forEach((btn) => {
|
||
btn.addEventListener('click', () => applyTimelineI18n(btn.dataset.lang));
|
||
});
|
||
applyTimelineI18n(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'),
|
||
corpusScopeControl: document.querySelector('#corpusScopeControl'),
|
||
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();
|
||
setupTimelineControls();
|
||
// 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('#exportDocxBtn')) downloadTimelineDocx();
|
||
if (e.target.closest('#txCopy')) copyTranscriptText();
|
||
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
|
||
if (e.target.closest('#dlSrt')) downloadTranscriptSrt();
|
||
if (e.target.closest('#dlVtt')) downloadTranscriptVtt();
|
||
if (e.target.closest('#rdlCopy')) copyRedactedText();
|
||
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
|
||
if (e.target.closest('#rdlDocx')) downloadRedactedDocx();
|
||
const copyBtn = e.target.closest('.timeline-copy-btn');
|
||
if (copyBtn) {
|
||
navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => {
|
||
const orig = copyBtn.innerHTML;
|
||
copyBtn.innerHTML = '✓';
|
||
setTimeout(() => { copyBtn.innerHTML = orig; }, 1200);
|
||
}).catch(() => {});
|
||
}
|
||
const chip = e.target.closest('.timeline-actor-chip');
|
||
if (chip) {
|
||
const actor = chip.dataset.actor;
|
||
if (activeActorFilters.has(actor)) {
|
||
activeActorFilters.delete(actor);
|
||
chip.classList.remove('is-active');
|
||
} else {
|
||
activeActorFilters.add(actor);
|
||
chip.classList.add('is-active');
|
||
}
|
||
applyTimelineFilters();
|
||
}
|
||
});
|
||
const activeTool = document.body.dataset.activeTool || state.activeTool;
|
||
if (els.form && tools[activeTool]) {
|
||
setTool(activeTool);
|
||
}
|
||
|
||
if (state.authenticated) {
|
||
checkHealth();
|
||
} else {
|
||
els.loginEmail?.focus();
|
||
}
|
||
});
|
||
|
||
function setTool(toolName) {
|
||
state.activeTool = toolName;
|
||
const tool = tools[toolName];
|
||
if (!tool || !els.toolKind || !els.input) return;
|
||
const serverRenderedShell = els.tabs.some((tab) => tab.tagName === 'A');
|
||
els.tabs.forEach((button) => {
|
||
const active = button.dataset.tool === toolName;
|
||
button.classList.toggle('is-active', active);
|
||
button.setAttribute('aria-pressed', String(active));
|
||
});
|
||
|
||
if (!serverRenderedShell) {
|
||
els.toolKind.textContent = tool.kind;
|
||
els.toolTitle.textContent = tool.title;
|
||
els.toolBadge.textContent = tool.badge;
|
||
els.inputLabel.textContent = tool.label;
|
||
}
|
||
els.input.value = '';
|
||
if (!serverRenderedShell) {
|
||
els.input.placeholder = tool.placeholder;
|
||
}
|
||
els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage);
|
||
els.corpusScopeControl?.classList.toggle('is-hidden', toolName !== 'search');
|
||
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;
|
||
if (!els.app) {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const dest = params.get('return') || '/';
|
||
window.location.href = dest.startsWith('/') && !dest.startsWith('//') ? dest : '/';
|
||
return;
|
||
}
|
||
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();
|
||
const docIds = (document.getElementById('docPickerIds')?.value || '')
|
||
.split(',').map(Number).filter(Boolean);
|
||
|
||
if (!text && !docIds.length) {
|
||
els.status.textContent = 'Add text or select a document before running the tool.';
|
||
if (!docIds.length) els.input.focus();
|
||
return;
|
||
}
|
||
|
||
const payload = { [tool.payloadKey]: text };
|
||
if (docIds.length) payload.doc_ids = docIds;
|
||
if (tool.usesLanguage) {
|
||
payload.language = currentLanguage();
|
||
}
|
||
if (state.activeTool === 'search') {
|
||
payload.limit = 7;
|
||
payload.corpus_scope = currentCorpusScope();
|
||
}
|
||
if (state.activeTool === 'redact') {
|
||
lastOriginalText = text;
|
||
payload.mode = currentRedactionMode();
|
||
payload.region = currentRedactionRegion();
|
||
payload.aliases = getAliases();
|
||
payload.engine = currentRedactEngine();
|
||
payload.output_format = currentOutputFormat();
|
||
payload.keep_officials = currentKeepOfficials();
|
||
payload.exempt_names = getExemptNames();
|
||
payload.redact_types = currentRedactTypes();
|
||
lastRedactPayload = { ...payload };
|
||
}
|
||
let timelineRouteNotice = '';
|
||
if (state.activeTool === 'timeline') {
|
||
payload.engine = currentTimelineEngine();
|
||
const clientRoute = timelineClientRoute(payload.engine, text.length);
|
||
payload.focus = currentTimelineFocus();
|
||
payload.confidence_filter = currentConfidenceFilter();
|
||
payload.include_relative = currentIncludeRelative();
|
||
payload.include_background = currentIncludeBackground();
|
||
payload.user_notes = (document.getElementById('timelineNotes')?.value || '').trim();
|
||
payload.use_my_case = (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false;
|
||
timelineRouteNotice = clientRoute.upgraded
|
||
? `This input is ${text.length.toLocaleString()} characters, so Timeline will use ${timelineEngineLabel(clientRoute.effective)} for reliability.`
|
||
: '';
|
||
}
|
||
|
||
lastToolPayload = { ...payload };
|
||
|
||
setBusy(true);
|
||
if (state.activeTool === 'redact') {
|
||
els.results.innerHTML = '<div class="redact-working" role="status" aria-live="polite"><span class="redact-working__spinner" aria-hidden="true"></span><p>Redacting document…</p></div>';
|
||
}
|
||
if (state.activeTool === 'timeline') {
|
||
const routeNotice = timelineRouteNotice ? `<p class="upload-hint">${escapeHtml(timelineRouteNotice)}</p>` : '';
|
||
els.results.innerHTML = `<div class="redact-working" id="timelineWorkingState" role="status" aria-live="polite"><span class="redact-working__spinner" aria-hidden="true"></span><p id="timelineStatusMsg">Building timeline…</p>${routeNotice}</div>`;
|
||
}
|
||
renderTrace([
|
||
{ label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
|
||
]);
|
||
|
||
try {
|
||
let data;
|
||
if (state.activeTool === 'timeline') {
|
||
const resp = await fetch('api/timeline-stream.php', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', event = '';
|
||
outer: while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
const lines = buf.split('\n');
|
||
buf = lines.pop();
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) { event = line.slice(7).trim(); continue; }
|
||
if (line.startsWith('data: ')) {
|
||
let parsed;
|
||
try { parsed = JSON.parse(line.slice(6)); } catch (_) { continue; }
|
||
if (event === 'status') {
|
||
const el = document.getElementById('timelineStatusMsg');
|
||
if (el) el.textContent = parsed.msg;
|
||
} else if (event === 'result') {
|
||
data = parsed;
|
||
} else if (event === 'error') {
|
||
throw new Error(parsed.message || 'Timeline failed');
|
||
}
|
||
event = '';
|
||
}
|
||
}
|
||
}
|
||
if (!data) throw new Error('No result received from timeline.');
|
||
} else {
|
||
data = await postJson(tool.endpoint, payload);
|
||
}
|
||
if (!data.ok) {
|
||
throw new Error(data.error?.message || 'Tool request failed.');
|
||
}
|
||
renderResults(data);
|
||
renderTrace(data.trace || []);
|
||
const routeMeta = data.trace_metadata || {};
|
||
const serverRouteNotice = state.activeTool === 'timeline' && routeMeta.auto_upgraded_engine
|
||
? ` Used ${timelineEngineLabel(routeMeta.effective_engine)} for ${Number(routeMeta.input_char_count || 0).toLocaleString()} characters.`
|
||
: '';
|
||
els.status.textContent = `Done in ${data.latency_ms || 0} ms.${serverRouteNotice}`;
|
||
if (['ask', 'redact', 'timeline'].includes(state.activeTool)) {
|
||
showSaveResultButton(state.activeTool, lastToolPayload, data, {
|
||
model: data.trace_metadata?.deployment || null,
|
||
latency_ms: data.latency_ms || 0,
|
||
});
|
||
}
|
||
// Offer "Run deep legal analysis" on ask/redact results
|
||
if (['ask', 'redact'].includes(state.activeTool)) {
|
||
const askText = state.activeTool === 'ask'
|
||
? ((lastToolPayload && lastToolPayload.question) || (data.answer || data.what_we_found || ''))
|
||
: (lastToolPayload && lastToolPayload.text) || (data.redacted_text || '');
|
||
const resEl = els.results || document.getElementById('results');
|
||
if (askText && resEl) {
|
||
dbnInjectLegalAnalysisButton(askText, (lastToolPayload && lastToolPayload.language) || 'no', state.activeTool, resEl);
|
||
}
|
||
}
|
||
} 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() {
|
||
if (!els.uploadZone || !els.uploadInput) return;
|
||
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);
|
||
});
|
||
|
||
// Stop label-for and the input itself from bubbling into the zone click
|
||
// handler — otherwise the picker opens twice (native + programmatic).
|
||
const _uploadLabel = els.uploadZone.querySelector('label[for="' + els.uploadInput.id + '"]');
|
||
if (_uploadLabel) _uploadLabel.addEventListener('click', (e) => e.stopPropagation());
|
||
els.uploadInput.addEventListener('click', (e) => e.stopPropagation());
|
||
els.uploadZone.addEventListener('click', (e) => {
|
||
if (e.target === els.uploadClear || els.uploadClear?.contains(e.target)) return;
|
||
if (e.target === els.uploadInput) return;
|
||
const lbl = e.target.closest && e.target.closest('label');
|
||
if (lbl && lbl.getAttribute('for') === els.uploadInput.id) 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, 1);
|
||
|
||
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 = `Extracting ${files[0].name}…`;
|
||
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[0].text;
|
||
|
||
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 = `Extracted ${totalChars.toLocaleString()} chars from ${parts[0].filename}${truncNote}.`;
|
||
} catch (err) {
|
||
els.status.textContent = err.message;
|
||
resetUpload();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function setupAliases() {
|
||
if (!els.addAliasRow || !els.aliasRows) return;
|
||
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.status === 402 || response.status === 429) {
|
||
dbnFreeTierError(response.status, data);
|
||
throw new Error(data.error?.message || (response.status === 429 ? 'rate_limit' : 'no_credits'));
|
||
}
|
||
const remaining = response.headers.get('X-Credits-Remaining');
|
||
if (remaining !== null) { dbnUpdateCredits(parseInt(remaining, 10)); }
|
||
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');
|
||
if (!button) return;
|
||
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 currentCorpusScope() {
|
||
return document.querySelector('input[name="corpusScope"]:checked')?.value || 'both';
|
||
}
|
||
|
||
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) {
|
||
lastRunEngine = data.trace_metadata?.deployment || null;
|
||
|
||
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>`);
|
||
}
|
||
|
||
sections.push(renderFeedbackWidget());
|
||
els.results.innerHTML = sections.join('');
|
||
setupFeedbackWidget(data.tool || state.activeTool);
|
||
|
||
if (data.tool === 'redact') setupRedactViewToggle();
|
||
|
||
const sortDoc = document.getElementById('sortDocOrder');
|
||
const sortChr = document.getElementById('sortChronological');
|
||
if (sortDoc && sortChr) {
|
||
sortDoc.addEventListener('click', () => {
|
||
timelineSortMode = 'doc';
|
||
sortDoc.classList.add('is-active');
|
||
sortChr.classList.remove('is-active');
|
||
applyTimelineFilters();
|
||
});
|
||
sortChr.addEventListener('click', () => {
|
||
timelineSortMode = 'chrono';
|
||
sortChr.classList.add('is-active');
|
||
sortDoc.classList.remove('is-active');
|
||
applyTimelineFilters();
|
||
});
|
||
}
|
||
const searchInput = document.getElementById('timelineSearch');
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', () => {
|
||
timelineSearchTerm = searchInput.value.trim().toLowerCase();
|
||
applyTimelineFilters();
|
||
});
|
||
}
|
||
const sourceToggle = document.getElementById('sourceToggle');
|
||
if (sourceToggle) {
|
||
sourceToggle.addEventListener('click', () => {
|
||
showSources = !showSources;
|
||
sourceToggle.textContent = showSources ? 'Hide sources' : 'Show sources';
|
||
document.querySelectorAll('.timeline-excerpt').forEach((el) => {
|
||
el.classList.toggle('is-hidden', !showSources);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Redact: tag colour helpers ─────────────────────────────────────────────
|
||
|
||
function tagColorClass(root) {
|
||
if (/^(FATHER|MOTHER|CHILD|GRANDPARENT|SIBLING|ATTORNEY|JUDGE|CASEWORKER|EXPERT_WITNESS|PERSON)/.test(root)) return 'person';
|
||
if (root === 'ORG') return 'org';
|
||
if (root === 'PLACE') return 'place';
|
||
if (/^(DATE|DOB)/.test(root)) return 'date';
|
||
return 'id';
|
||
}
|
||
|
||
function highlightRedactedText(text) {
|
||
const escaped = escapeHtml(text);
|
||
return escaped.replace(/\[([A-Z][A-Z0-9_-]*)(?::\s*([^\]]+))?\]/g, (_m, root, suffix) => {
|
||
const cls = tagColorClass(root);
|
||
const display = suffix ? `[${root}: ${suffix}]` : `[${root}]`;
|
||
return `<span class="redact-tag redact-tag--${cls}">${display}</span>`;
|
||
});
|
||
}
|
||
|
||
function renderRedactionInventory(redactionMap, entityCounts) {
|
||
const map = redactionMap || {};
|
||
const counts = entityCounts || {};
|
||
const entries = [];
|
||
|
||
for (const [tag, info] of Object.entries(map)) {
|
||
const root = tag.replace(/^\[|\]$/g, '').split(':')[0].trim();
|
||
entries.push({ tag, originals: info.originals || [], occurrences: info.occurrences || 0, cls: tagColorClass(root) });
|
||
}
|
||
|
||
const mappedTypes = new Set(Object.values(map).map(e => e.type));
|
||
for (const [type, count] of Object.entries(counts)) {
|
||
if (Number(count) > 0 && !mappedTypes.has(type)) {
|
||
entries.push({ tag: type, originals: [], occurrences: Number(count), cls: tagColorClass(type.toUpperCase()) });
|
||
}
|
||
}
|
||
|
||
if (!entries.length) return '';
|
||
|
||
const rows = entries.map(e => {
|
||
const tagSpan = `<span class="redact-tag redact-tag--${e.cls}">${escapeHtml(e.tag)}</span>`;
|
||
const originalsHtml = e.originals.length
|
||
? `<span class="inv-originals">→ ${e.originals.map(o => `<em>${escapeHtml(o)}</em>`).join(', ')}</span> `
|
||
: '';
|
||
const countHtml = e.occurrences > 0 ? `<span class="inv-count">${e.occurrences}×</span>` : '';
|
||
return `<li>${tagSpan} ${originalsHtml}${countHtml}</li>`;
|
||
}).join('');
|
||
|
||
return `<details class="redact-inventory" open>
|
||
<summary>Redaction inventory <span class="inv-badge">${entries.length}</span></summary>
|
||
<ul class="inv-list">${rows}</ul>
|
||
</details>`;
|
||
}
|
||
|
||
async function rerunWithBetterEngine() {
|
||
if (!lastRedactPayload) return;
|
||
const btn = document.getElementById('rerunBetterBtn');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Running…'; }
|
||
const payload = { ...lastRedactPayload, engine: 'azure_full' };
|
||
lastRedactPayload = payload;
|
||
setBusy(true);
|
||
renderTrace([{ label: 'Re-run with gpt-4o', detail: 'Submitting same text with Azure gpt-4o engine.', status: 'running' }]);
|
||
try {
|
||
const data = await postJson('api/redact.php', payload);
|
||
if (!data.ok) throw new Error(data.error?.message || 'Re-run failed.');
|
||
renderResults(data);
|
||
renderTrace(data.trace || []);
|
||
els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
|
||
} catch (error) {
|
||
els.status.textContent = error.message;
|
||
renderTrace([{ label: 'Tool error', detail: error.message, status: 'warning' }]);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function setupRedactViewToggle() {
|
||
const pre = document.getElementById('redactOutputPre');
|
||
const btnRedacted = document.getElementById('viewRedacted');
|
||
const btnOriginal = document.getElementById('viewOriginal');
|
||
const rerunBtn = document.getElementById('rerunBetterBtn');
|
||
if (btnRedacted && pre) {
|
||
btnRedacted.addEventListener('click', () => {
|
||
pre.innerHTML = highlightRedactedText(lastRedactedText || '');
|
||
btnRedacted.classList.add('is-active');
|
||
if (btnOriginal) btnOriginal.classList.remove('is-active');
|
||
});
|
||
}
|
||
if (btnOriginal && pre) {
|
||
btnOriginal.addEventListener('click', () => {
|
||
pre.textContent = lastOriginalText || '';
|
||
btnOriginal.classList.add('is-active');
|
||
if (btnRedacted) btnRedacted.classList.remove('is-active');
|
||
});
|
||
}
|
||
if (rerunBtn) rerunBtn.addEventListener('click', rerunWithBetterEngine);
|
||
}
|
||
|
||
// ── Main finding renderer ───────────────────────────────────────────────────
|
||
|
||
function renderMainFinding(data) {
|
||
if (data.tool === 'ask') {
|
||
return `<p class="answer">${escapeHtml(data.answer || data.what_we_found || '')}</p>`;
|
||
}
|
||
if (data.tool === 'redact') {
|
||
lastRedactedText = data.redacted_text || '';
|
||
const t = (k) => currentRedactT(k) || k;
|
||
|
||
const viewToggle = `<div class="redact-view-toggle">
|
||
<button type="button" class="view-btn is-active" id="viewRedacted">Redacted</button>
|
||
<button type="button" class="view-btn" id="viewOriginal">Original</button>
|
||
</div>`;
|
||
|
||
const inventoryHtml = renderRedactionInventory(data.redaction_map, data.entity_counts);
|
||
|
||
const isNotBestEngine = data.engine_used && data.engine_used !== 'Azure gpt-4o' && data.engine_used !== 'gpt-4o';
|
||
const upgradeBtn = isNotBestEngine
|
||
? `<button type="button" class="upgrade-engine-btn" id="rerunBetterBtn">Re-run with gpt-4o for higher accuracy →</button>`
|
||
: '';
|
||
|
||
const dlRow = `<div class="redact-downloads">
|
||
<button type="button" class="redact-dl-btn" id="rdlCopy">${t('redactCopy')}</button>
|
||
<button type="button" class="redact-dl-btn" id="rdlTxt">${t('redactDownloadTxt')}</button>
|
||
<button type="button" class="redact-dl-btn" id="rdlDocx">${t('redactDownloadDocx')}</button>
|
||
</div>`;
|
||
|
||
return `${viewToggle}<pre class="redacted-output" id="redactOutputPre">${highlightRedactedText(lastRedactedText)}</pre>${inventoryHtml}${upgradeBtn}${dlRow}`;
|
||
}
|
||
if (data.tool === 'timeline') {
|
||
lastTimelineEventsOriginal = data.events || [];
|
||
lastTimelineEvents = [...lastTimelineEventsOriginal];
|
||
lastTimelineWhatWeFound = data.what_we_found || '';
|
||
lastTimelineInputDateHintCount = data.trace_metadata?.input_date_hint_count ?? null;
|
||
activeActorFilters = new Set();
|
||
timelineSearchTerm = '';
|
||
showSources = true;
|
||
timelineSortMode = 'doc';
|
||
const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV';
|
||
const exportRow = lastTimelineEventsOriginal.length
|
||
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button><button type="button" id="exportDocxBtn" class="export-csv-btn">Export to Word</button></div>`
|
||
: '';
|
||
const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal);
|
||
const actorChips = buildActorChips(lastTimelineEventsOriginal);
|
||
const toolbar = buildTimelineToolbar();
|
||
const sortBar = lastTimelineEventsOriginal.length > 1 ? `
|
||
<div class="timeline-sort-bar">
|
||
<span class="sort-label">Sort:</span>
|
||
<button type="button" class="sort-btn is-active" id="sortDocOrder">${escapeHtml(currentTimelineT('sortDocOrder') || 'Document order')}</button>
|
||
<button type="button" class="sort-btn" id="sortChronological">${escapeHtml(currentTimelineT('sortChronological') || 'Chronological')}</button>
|
||
</div>` : '';
|
||
return `<p>${escapeHtml(lastTimelineWhatWeFound)}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${exportRow}`;
|
||
}
|
||
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 buildTimelineCountBadge(events) {
|
||
if (!events.length) return '';
|
||
const actors = new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'));
|
||
const isoDates = events.map((e) => e.date || '').filter((d) => /^\d{4}/.test(d)).sort();
|
||
let rangeStr = '';
|
||
if (isoDates.length) {
|
||
const y0 = isoDates[0].slice(0, 4);
|
||
const y1 = isoDates[isoDates.length - 1].slice(0, 4);
|
||
rangeStr = y0 === y1 ? ` · ${y0}` : ` · ${y0}–${y1}`;
|
||
}
|
||
const ac = actors.size;
|
||
return `<p class="timeline-count-badge">${events.length} event${events.length !== 1 ? 's' : ''}${ac ? ` · ${ac} actor${ac !== 1 ? 's' : ''}` : ''}${rangeStr}</p>`;
|
||
}
|
||
|
||
function buildActorChips(events) {
|
||
const actors = [...new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'))].sort();
|
||
if (actors.length < 2) return '';
|
||
const chips = actors.map((a) =>
|
||
`<button type="button" class="timeline-actor-chip" data-actor="${escapeHtml(a)}">${escapeHtml(a)}</button>`
|
||
).join('');
|
||
return `<div class="timeline-actor-chips" id="actorChips">${chips}</div>`;
|
||
}
|
||
|
||
function buildTimelineToolbar() {
|
||
return `<div class="timeline-toolbar">
|
||
<input type="search" id="timelineSearch" class="timeline-search" placeholder="Search events…" aria-label="Filter events by keyword">
|
||
<button type="button" id="sourceToggle" class="source-toggle-btn">Hide sources</button>
|
||
</div>`;
|
||
}
|
||
|
||
function applyTimelineFilters() {
|
||
let events = timelineSortMode === 'chrono'
|
||
? sortChronological([...lastTimelineEventsOriginal])
|
||
: [...lastTimelineEventsOriginal];
|
||
if (activeActorFilters.size > 0) {
|
||
events = events.filter((e) => activeActorFilters.has(e.actor));
|
||
}
|
||
if (timelineSearchTerm) {
|
||
const q = timelineSearchTerm;
|
||
events = events.filter((e) =>
|
||
(e.event || '').toLowerCase().includes(q) ||
|
||
(e.actor || '').toLowerCase().includes(q) ||
|
||
(e.source_excerpt || '').toLowerCase().includes(q) ||
|
||
(e.date || '').toLowerCase().includes(q)
|
||
);
|
||
}
|
||
lastTimelineEvents = events;
|
||
const container = document.getElementById('timelineListContainer');
|
||
if (container) container.innerHTML = renderTimeline(events, timelineSortMode === 'chrono');
|
||
}
|
||
|
||
function renderTimeline(events, grouped = false) {
|
||
if (!events.length) {
|
||
if (lastTimelineInputDateHintCount === 0) {
|
||
return '<p class="timeline-empty">No recognizable dates were found in the extracted text. Check that the upload is text-searchable, or paste the relevant dated section and run again.</p>';
|
||
}
|
||
return '<p class="timeline-empty">No matching events.</p>';
|
||
}
|
||
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
let lastGroupKey = null;
|
||
const items = events.map((ev) => {
|
||
const conf = ev.confidence || 'medium';
|
||
let groupHeader = '';
|
||
if (grouped && /^\d{4}-\d{2}/.test(ev.date || '')) {
|
||
const year = ev.date.slice(0, 4);
|
||
const mon = ev.date.slice(5, 7);
|
||
const key = `${year}-${mon}`;
|
||
if (key !== lastGroupKey) {
|
||
const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null;
|
||
const label = (year !== prevYear)
|
||
? year
|
||
: `${MONTH_NAMES[parseInt(mon, 10) - 1] || mon} ${year}`;
|
||
groupHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
|
||
lastGroupKey = key;
|
||
}
|
||
}
|
||
const excerptHtml = ev.source_excerpt
|
||
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
|
||
: '';
|
||
const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · ');
|
||
return `${groupHeader}<li class="timeline-item confidence-${escapeHtml(conf)}">
|
||
<div class="timeline-header">
|
||
<strong class="timeline-date">${escapeHtml(ev.date || 'unknown')}${ev.time ? `<span class="timeline-time"> ${escapeHtml(ev.time)}</span>` : ''}</strong>
|
||
${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''}
|
||
<span class="confidence-badge confidence-badge--${escapeHtml(conf)}">${escapeHtml(conf)}</span>
|
||
<button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy event" aria-label="Copy event to clipboard">📋</button>
|
||
</div>
|
||
<span class="timeline-actor">${escapeHtml(ev.actor || 'unknown actor')}</span>
|
||
<p class="timeline-event">${escapeHtml(ev.event || '')}</p>
|
||
${excerptHtml}
|
||
</li>`;
|
||
}).join('');
|
||
return `<ol class="timeline-list">${items}</ol>`;
|
||
}
|
||
|
||
function renderFeedbackWidget() {
|
||
return `
|
||
<div class="feedback-widget" id="feedbackWidget">
|
||
<p class="feedback-label">Was this result useful?</p>
|
||
<div class="feedback-btns" id="feedbackBtns">
|
||
<button type="button" class="feedback-thumb" data-rating="positive" aria-label="Helpful">👍</button>
|
||
<button type="button" class="feedback-thumb" data-rating="negative" aria-label="Not helpful">👎</button>
|
||
</div>
|
||
<div class="feedback-detail is-hidden" id="feedbackDetail">
|
||
<label class="feedback-detail-label" for="feedbackMissed">What was missed or wrong? <span class="feedback-optional">(optional)</span></label>
|
||
<textarea id="feedbackMissed" class="feedback-textarea" rows="3" placeholder="e.g. missed dates: 18.09.25, 6.1. — or names that should have been redacted"></textarea>
|
||
<div class="feedback-detail-footer">
|
||
<button type="button" id="feedbackSubmit" class="feedback-submit-btn">Submit feedback</button>
|
||
<p id="feedbackStatus" class="feedback-status" role="status" aria-live="polite"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function setupFeedbackWidget(tool) {
|
||
const widget = document.getElementById('feedbackWidget');
|
||
const detail = document.getElementById('feedbackDetail');
|
||
const submit = document.getElementById('feedbackSubmit');
|
||
const status = document.getElementById('feedbackStatus');
|
||
const missed = document.getElementById('feedbackMissed');
|
||
if (!widget) return;
|
||
|
||
let chosenRating = null;
|
||
|
||
widget.querySelectorAll('.feedback-thumb').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
chosenRating = btn.dataset.rating;
|
||
widget.querySelectorAll('.feedback-thumb').forEach((b) => b.classList.remove('is-active'));
|
||
btn.classList.add('is-active');
|
||
detail.classList.remove('is-hidden');
|
||
missed.focus();
|
||
});
|
||
});
|
||
|
||
submit.addEventListener('click', async () => {
|
||
if (!chosenRating) return;
|
||
submit.disabled = true;
|
||
status.textContent = 'Saving…';
|
||
try {
|
||
const resp = await fetch('api/feedback.php', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tool,
|
||
rating: chosenRating,
|
||
missed_or_wrong: missed.value.trim(),
|
||
engine: lastRunEngine || '',
|
||
}),
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Save failed');
|
||
widget.innerHTML = '<p class="feedback-thanks">Thanks for your feedback!</p>';
|
||
} catch (err) {
|
||
status.textContent = err.message;
|
||
submit.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
function exportTimelineCSV(events) {
|
||
const header = ['Date', 'End Date', 'Time', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence'];
|
||
const rows = events.map((ev) => [
|
||
ev.date || '', ev.end_date || '', ev.time || '', 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);
|
||
}
|
||
|
||
async function downloadTimelineDocx() {
|
||
const btn = document.getElementById('exportDocxBtn');
|
||
if (!btn || !lastTimelineEventsOriginal.length) return;
|
||
const origText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Generating…';
|
||
try {
|
||
const resp = await fetch('api/timeline-download.php', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ events: lastTimelineEventsOriginal, what_we_found: lastTimelineWhatWeFound, format: 'docx' }),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.error?.message || 'Download failed');
|
||
}
|
||
const blob = await resp.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = Object.assign(document.createElement('a'), { href: url, download: 'timeline.docx' });
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
alert('Could not export Word file: ' + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = origText;
|
||
}
|
||
}
|
||
|
||
function currentTask() {
|
||
const el = document.querySelector('input[name="task"]:checked');
|
||
return el ? el.value : 'transcribe';
|
||
}
|
||
|
||
function buildTranscribeError(err, file) {
|
||
const name = file?.name ?? 'file';
|
||
const sizeMB = file?.size ? (file.size / 1024 / 1024).toFixed(1) : null;
|
||
let reason = err?.message || 'Unknown error';
|
||
if (/too large|size/i.test(reason) || reason.includes('413'))
|
||
reason = `File too large${sizeMB ? ` (${sizeMB} MB)` : ''}`;
|
||
else if (/format|unsupported|415/i.test(reason))
|
||
reason = 'Unsupported audio format';
|
||
else if (/timeout|timed out|504/i.test(reason))
|
||
reason = 'Request timed out — try a shorter clip';
|
||
else if (/50[0-9]/.test(reason))
|
||
reason = 'Server error';
|
||
return `${name}: ${reason}`;
|
||
}
|
||
|
||
function showTranscribeProgress(clip, total) {
|
||
if (!els.results) return;
|
||
const pct = total > 1 ? Math.round(((clip - 1) / total) * 100) : null;
|
||
const label = total > 1 ? `Clip ${clip} / ${total}` : 'Transcribing…';
|
||
const barClass = pct !== null ? '' : ' is-indeterminate';
|
||
const barStyle = pct !== null ? ` style="width:${pct}%"` : '';
|
||
els.results.innerHTML = `
|
||
<div class="transcribe-progress-wrap">
|
||
<div class="transcribe-progress-track">
|
||
<div class="transcribe-progress-fill${barClass}"${barStyle}></div>
|
||
</div>
|
||
<p class="transcribe-progress-label">${escapeHtml(label)}</p>
|
||
</div>`;
|
||
}
|
||
|
||
async function runTranscribe() {
|
||
const storedAudioDocId = parseInt(document.getElementById('audioPickerDocId')?.value || '0', 10);
|
||
|
||
// Stored audio path — no file in the queue required
|
||
if (storedAudioDocId > 0 && !audioQueue.length) {
|
||
setBusy(true);
|
||
els.status.textContent = 'Transcribing from corpus…';
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('audio_doc_id', String(storedAudioDocId));
|
||
fd.append('language', currentTranscribeLang ? currentTranscribeLang() : 'auto');
|
||
fd.append('task', currentTask ? currentTask() : 'transcribe');
|
||
const vadFilter = document.getElementById('vadFilterCheck')?.checked ?? false;
|
||
if (vadFilter) fd.append('vad_filter', '1');
|
||
const initPrompt = (document.getElementById('initPromptInput')?.value || '').trim();
|
||
if (initPrompt) fd.append('initial_prompt', initPrompt);
|
||
const whisperModel = document.getElementById('whisperModelSelect')?.value;
|
||
if (whisperModel) fd.append('model', whisperModel);
|
||
const postModel = document.querySelector('input[name="post_model"]:checked')?.value;
|
||
if (postModel) fd.append('post_model', postModel);
|
||
const diarize = document.getElementById('diarizeCheck')?.checked ?? false;
|
||
if (diarize) {
|
||
fd.append('diarize', '1');
|
||
const ns = parseInt(document.getElementById('numSpeakersInput')?.value || '', 10);
|
||
if (ns >= 2) fd.append('num_speakers', String(ns));
|
||
}
|
||
const resp = await fetch('api/transcribe.php', { method: 'POST', credentials: 'same-origin', body: fd });
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Transcription failed.');
|
||
if (typeof data.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
|
||
window.dbnUpdateCredits(data.balance);
|
||
}
|
||
if (typeof renderTranscribeResult === 'function') renderTranscribeResult(data);
|
||
else renderResults(data);
|
||
showSaveResultButton('transcribe', { audio_doc_id: storedAudioDocId }, data, {
|
||
model: data.model || null,
|
||
latency_ms: data.latency_ms || 0,
|
||
});
|
||
els.status.textContent = 'Done.';
|
||
} catch (err) {
|
||
els.status.textContent = err.message;
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!audioQueue.length) {
|
||
els.status.textContent = currentUiT('noFileSelected');
|
||
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; item.errorMsg = 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();
|
||
|
||
// Show progress bar
|
||
showTranscribeProgress(i + 1, total);
|
||
|
||
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, clipLabel);
|
||
}, 1000);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('audio', item.file);
|
||
formData.append('language', currentTranscribeLang());
|
||
formData.append('task', currentTask());
|
||
formData.append('time_offset', String(cumulativeOffset));
|
||
if (vadFilter) formData.append('vad_filter', '1');
|
||
if (initPrompt) formData.append('initial_prompt', initPrompt);
|
||
const whisperModel = document.getElementById('whisperModelSelect')?.value;
|
||
if (whisperModel) formData.append('model', whisperModel);
|
||
const postModel = document.querySelector('input[name="post_model"]:checked')?.value;
|
||
if (postModel) formData.append('post_model', postModel);
|
||
if (diarize) {
|
||
formData.append('diarize', '1');
|
||
if (numSpeakers >= 2) formData.append('num_speakers', String(numSpeakers));
|
||
}
|
||
|
||
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));
|
||
}
|
||
if (typeof data.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
|
||
window.dbnUpdateCredits(data.balance);
|
||
}
|
||
|
||
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';
|
||
item.errorMsg = buildTranscribeError(err, item.file);
|
||
renderAudioQueue();
|
||
// Continue processing remaining clips rather than halting
|
||
}
|
||
|
||
renderAudioQueue();
|
||
}
|
||
|
||
// Merge results from successful clips
|
||
const errorItems = audioQueue.filter((it) => it.status === 'error');
|
||
if (errorItems.length && !lastResult) {
|
||
// All clips failed
|
||
const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
|
||
els.status.textContent = `Failed: ${errList}`;
|
||
renderTrace([{ label: 'Transcription failed', detail: errList, status: 'warning' }]);
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
|
||
const merged = {
|
||
...lastResult,
|
||
transcript: allTranscripts.join('\n\n'),
|
||
segments: allSegments,
|
||
speaker_roles: firstSpeakerRoles,
|
||
num_speakers: lastResult?.num_speakers ?? 0,
|
||
duration_sec: cumulativeOffset,
|
||
};
|
||
|
||
lastTranscriptData = merged;
|
||
renderTranscriptResults(merged);
|
||
showSaveResultButton('transcribe', lastToolPayload || {}, merged, {
|
||
model: merged.model || null,
|
||
latency_ms: merged.latency_ms || 0,
|
||
});
|
||
|
||
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`;
|
||
if (errorItems.length) {
|
||
const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
|
||
els.status.textContent = `Done (${errorItems.length} failed: ${errList})`;
|
||
} else {
|
||
els.status.textContent = currentUiT('done', total, durLabel);
|
||
}
|
||
setBusy(false);
|
||
}
|
||
|
||
function updateTranscribeTrace(elapsed, clipLabel) {
|
||
if (!clipLabel) clipLabel = currentUiT('clipLabel', 1, 1);
|
||
let label, detail;
|
||
if (elapsed < 10) {
|
||
label = currentUiT('traceUploadLabel', clipLabel);
|
||
detail = currentUiT('traceUploadDetail');
|
||
} else if (elapsed < 60) {
|
||
label = currentUiT('traceProcessingLabel', clipLabel);
|
||
detail = currentUiT('traceProcessingDetail');
|
||
} 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))];
|
||
|
||
// Speaker role tags (shown above transcript)
|
||
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>`
|
||
: '';
|
||
|
||
// Segments panel — includes inline speaker legend at the top
|
||
const legendHtml = speakerOrder.length
|
||
? `<div class="segment-legend">${speakerOrder.map((id, i) => {
|
||
const role = speakerRoles[id] || id;
|
||
return `<span class="speaker-tag speaker-tag--${i % 6}">${escapeHtml(role)}<small>${escapeHtml(id)}</small></span>`;
|
||
}).join('')}</div>`
|
||
: '';
|
||
|
||
const segmentsHtml = hasSpeakers
|
||
? `<details class="segment-details"><summary class="segment-summary">Segments (${segments.length})</summary>
|
||
${legendHtml}
|
||
<div class="segment-list">${segments.map((seg) => {
|
||
const idx = speakerOrder.indexOf(seg.speaker);
|
||
const roleLabel = seg.speaker && speakerRoles[seg.speaker]
|
||
? `${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>`
|
||
: '';
|
||
|
||
// SRT/VTT downloads (only if segments available)
|
||
const dlSrtVtt = segments.length
|
||
? `<div class="transcript-downloads">
|
||
<button type="button" class="export-csv-btn" id="dlSrt">Download SRT</button>
|
||
<button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>
|
||
</div>`
|
||
: '';
|
||
|
||
// Meta stats row
|
||
const durStr = data.duration_sec ? `⏱ ${fmtDuration(data.duration_sec)}` : null;
|
||
const langStr = data.language ? `🌐 ${data.language.toUpperCase()}` : null;
|
||
const spkStr = data.num_speakers > 1 ? `🗣 ${data.num_speakers} speakers` : null;
|
||
const metaParts = [durStr, langStr, spkStr].filter(Boolean);
|
||
const metaRow = metaParts.length
|
||
? `<div class="transcript-meta-row">${metaParts.map((p) => `<span>${p}</span>`).join('')}</div>`
|
||
: '';
|
||
|
||
// AI cleanup badge inline with engine label
|
||
const cleanupBadge = data.cleaned_by
|
||
? ` <span class="cleanup-badge">✓ Cleaned by ${escapeHtml(data.cleaned_by)}</span>`
|
||
: '';
|
||
const engineLine = data.model
|
||
? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong>${cleanupBadge}</p>`
|
||
: '';
|
||
|
||
// Action row above transcript (copy + TXT download)
|
||
const actionRow = `
|
||
<div class="transcript-actions">
|
||
<button type="button" class="export-csv-btn" id="txCopy">Copy</button>
|
||
<button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
|
||
</div>`;
|
||
|
||
lastRunEngine = data.engine || null;
|
||
els.results.innerHTML = `
|
||
<section class="result-section">
|
||
<h3>Transcript</h3>
|
||
${engineLine}
|
||
${metaRow}
|
||
${rolesHtml}
|
||
${actionRow}
|
||
<div class="transcript-box"><pre class="transcript-text">${escapeHtml(data.transcript)}</pre></div>
|
||
${segmentsHtml}
|
||
${dlSrtVtt}
|
||
</section>
|
||
${renderFeedbackWidget()}`;
|
||
setupFeedbackWidget('transcribe');
|
||
|
||
const traceMeta = [];
|
||
if (data.duration_sec) traceMeta.push({ label: `Duration: ${fmtDuration(data.duration_sec)}`, detail: '', status: 'complete' });
|
||
if (data.language) traceMeta.push({ label: `Language: ${data.language}`, detail: '', status: 'complete' });
|
||
if (data.num_speakers > 1) traceMeta.push({ label: `Speakers: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
|
||
if (data.model) traceMeta.push({ label: data.model, detail: '', status: 'complete' });
|
||
if (data.cleaned_by) traceMeta.push({ label: `Cleaned by ${data.cleaned_by}`, detail: '', status: 'complete' });
|
||
renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]);
|
||
}
|
||
|
||
function fmtDuration(secs) {
|
||
const m = Math.floor(secs / 60);
|
||
const s = Math.round(secs % 60);
|
||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||
}
|
||
|
||
async function copyTranscriptText() {
|
||
if (!lastTranscriptData?.transcript) return;
|
||
const btn = document.getElementById('txCopy');
|
||
try {
|
||
await navigator.clipboard.writeText(lastTranscriptData.transcript);
|
||
if (btn) {
|
||
const orig = btn.textContent;
|
||
btn.textContent = 'Copied!';
|
||
setTimeout(() => { btn.textContent = orig; }, 1800);
|
||
}
|
||
} catch {
|
||
// clipboard unavailable — silently ignore
|
||
}
|
||
}
|
||
|
||
function fmtTime(secs) {
|
||
const h = Math.floor(secs / 3600);
|
||
const m = Math.floor((secs % 3600) / 60);
|
||
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);
|
||
});
|
||
|
||
// Stop label-for and the input itself from bubbling into the zone click
|
||
// handler — otherwise the picker opens twice (native + programmatic).
|
||
const _audioLabel = els.audioZone.querySelector('label[for="' + els.audioInput.id + '"]');
|
||
if (_audioLabel) _audioLabel.addEventListener('click', (e) => e.stopPropagation());
|
||
els.audioInput.addEventListener('click', (e) => e.stopPropagation());
|
||
els.audioZone.addEventListener('click', (e) => {
|
||
if (e.target === els.audioClear || els.audioClear?.contains(e.target)) return;
|
||
if (e.target === els.audioInput) return;
|
||
const lbl = e.target.closest && e.target.closest('label');
|
||
if (lbl && lbl.getAttribute('for') === els.audioInput.id) return;
|
||
if (e.target.closest('#audioFileInfo')) 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() {
|
||
// engine auto-selected server-side
|
||
}
|
||
|
||
function setupVocabPresets() {
|
||
if (!els.vocabPresets) return;
|
||
|
||
const vocabCountEl = document.getElementById('vocabCharCount');
|
||
function updateVocabCount() {
|
||
if (!vocabCountEl || !els.initPromptInput) return;
|
||
const n = els.initPromptInput.value.length;
|
||
vocabCountEl.textContent = n;
|
||
vocabCountEl.closest('small')?.classList.toggle('vocab-char-count--warn', n > 450);
|
||
}
|
||
els.initPromptInput?.addEventListener('input', updateVocabCount);
|
||
|
||
els.vocabPresets.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.vocab-btn');
|
||
if (!btn) return;
|
||
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();
|
||
updateVocabCount();
|
||
}
|
||
});
|
||
}
|
||
|
||
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("'", ''');
|
||
}
|
||
|
||
// ── Free-tier credit badge ────────────────────────────────────────────────
|
||
|
||
// ── Save result widget ─────────────────────────────────────────────────────
|
||
|
||
const _SAVEABLE_TOOL_LABELS = {
|
||
ask: 'Spørsmål & svar',
|
||
redact: 'Anonymisering',
|
||
timeline: 'Tidslinje',
|
||
transcribe: 'Transkripsjon',
|
||
summarize: 'Sammendrag',
|
||
barnevernet: 'BVJ-analyse',
|
||
'deep-research': 'Dyp analyse',
|
||
advocate: 'Advokatutkast',
|
||
discrepancy: 'Motstrid',
|
||
korrespond: 'Korrespondanse',
|
||
};
|
||
|
||
function _isSaveEligibleUser() {
|
||
const tier = window.DBN_USER_TIER;
|
||
return typeof tier === 'string' && !['free', 'caveau'].includes(tier);
|
||
}
|
||
|
||
function showSaveResultButton(tool, inputPayload, outputPayload, meta, containerEl) {
|
||
if (!_isSaveEligibleUser()) return;
|
||
if (!Object.prototype.hasOwnProperty.call(_SAVEABLE_TOOL_LABELS, tool)) return;
|
||
|
||
document.getElementById('saveResultWidget')?.remove();
|
||
|
||
const label = _SAVEABLE_TOOL_LABELS[tool] || tool;
|
||
const now = new Date();
|
||
const dateStr = now.toLocaleDateString('no-NO', { day: 'numeric', month: 'short', year: 'numeric' });
|
||
const timeStr = now.toLocaleTimeString('no-NO', { hour: '2-digit', minute: '2-digit' });
|
||
const dfTitle = `${label} — ${dateStr} ${timeStr}`;
|
||
|
||
const widget = document.createElement('div');
|
||
widget.id = 'saveResultWidget';
|
||
widget.className = 'save-result-widget';
|
||
widget.innerHTML = `
|
||
<div class="save-result-idle">
|
||
<button type="button" class="save-result-btn" id="saveResultTrigger">Save to My Docs</button>
|
||
</div>
|
||
<div class="save-result-prompt is-hidden">
|
||
<input type="text" class="save-result-input" id="saveResultTitle" maxlength="200" aria-label="Result title">
|
||
<button type="button" class="save-result-confirm" id="saveResultConfirm">Save</button>
|
||
<button type="button" class="save-result-cancel" id="saveResultCancel">Cancel</button>
|
||
</div>
|
||
<div class="save-result-done is-hidden">
|
||
<a class="save-result-link" href="min-sak.php">✓ Saved to My Docs ↗</a>
|
||
</div>
|
||
<p class="save-result-error is-hidden"></p>`;
|
||
|
||
const resultsEl = containerEl || document.getElementById('results');
|
||
if (!resultsEl) return;
|
||
resultsEl.insertBefore(widget, resultsEl.firstChild);
|
||
|
||
// Set value after inserting so escaping is handled by the DOM
|
||
widget.querySelector('#saveResultTitle').value = dfTitle;
|
||
|
||
const triggerBtn = widget.querySelector('#saveResultTrigger');
|
||
const promptDiv = widget.querySelector('.save-result-prompt');
|
||
const idleDiv = widget.querySelector('.save-result-idle');
|
||
const doneDiv = widget.querySelector('.save-result-done');
|
||
const errorP = widget.querySelector('.save-result-error');
|
||
const titleInput = widget.querySelector('#saveResultTitle');
|
||
const confirmBtn = widget.querySelector('#saveResultConfirm');
|
||
const cancelBtn = widget.querySelector('#saveResultCancel');
|
||
|
||
triggerBtn.addEventListener('click', () => {
|
||
idleDiv.classList.add('is-hidden');
|
||
promptDiv.classList.remove('is-hidden');
|
||
titleInput.focus();
|
||
titleInput.select();
|
||
});
|
||
|
||
cancelBtn.addEventListener('click', () => {
|
||
promptDiv.classList.add('is-hidden');
|
||
idleDiv.classList.remove('is-hidden');
|
||
});
|
||
|
||
titleInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') confirmBtn.click();
|
||
if (e.key === 'Escape') cancelBtn.click();
|
||
});
|
||
|
||
confirmBtn.addEventListener('click', async () => {
|
||
const title = titleInput.value.trim();
|
||
if (!title) { titleInput.focus(); return; }
|
||
|
||
confirmBtn.disabled = true;
|
||
confirmBtn.textContent = 'Saving…';
|
||
errorP.classList.add('is-hidden');
|
||
|
||
try {
|
||
const resp = await fetch('api/case/save-result.php', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tool,
|
||
title,
|
||
input_payload: inputPayload || {},
|
||
output_payload: outputPayload || {},
|
||
meta: meta || {},
|
||
}),
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Save failed.');
|
||
promptDiv.classList.add('is-hidden');
|
||
doneDiv.classList.remove('is-hidden');
|
||
} catch (err) {
|
||
confirmBtn.disabled = false;
|
||
confirmBtn.textContent = 'Save';
|
||
errorP.textContent = err.message;
|
||
errorP.classList.remove('is-hidden');
|
||
}
|
||
});
|
||
}
|
||
|
||
window.dbnShowSaveResultButton = showSaveResultButton;
|
||
|
||
// ── Legal Analysis add-on (Run deep legal analysis on any text/result) ──────
|
||
// Injects a button that, when clicked, streams the two-pass legal-analysis flow
|
||
// (extract issues → ask dbn-legal-agent-v3 → synthesise) into a sub-section
|
||
// below the host container.
|
||
function _laT(key, vars) {
|
||
const map = window.DBN_ADDON_I18N || {};
|
||
let s = map[key] || key;
|
||
if (vars) {
|
||
Object.keys(vars).forEach((k) => { s = s.split('{' + k + '}').join(String(vars[k])); });
|
||
}
|
||
return s;
|
||
}
|
||
function dbnRunLegalAnalysisAddon(text, lang, sourceTool, containerEl) {
|
||
if (!containerEl) containerEl = document.getElementById('results');
|
||
if (!containerEl || !text || text.length < 80) return;
|
||
|
||
// Remove any prior legal-analysis section
|
||
const prior = containerEl.parentNode.querySelector('.la-addon-section');
|
||
if (prior) prior.remove();
|
||
|
||
const section = document.createElement('section');
|
||
section.className = 'la-addon-section result-section';
|
||
section.style.marginTop = '1.2rem';
|
||
section.style.padding = '1rem 1.2rem';
|
||
section.style.border = '1px dashed var(--dbn-teal, #0f766e)';
|
||
section.style.borderRadius = '8px';
|
||
section.style.background = '#f0fdfa';
|
||
section.innerHTML =
|
||
'<h3 style="margin-top:0;color:#0e7490;">⚖️🇳🇴 ' + escapeHtml(_laT('addonSection')) + '</h3>'
|
||
+ '<div class="la-pipeline" style="margin-bottom:1rem;">'
|
||
+ '<div class="la-step running"><strong>' + escapeHtml(_laT('pass1')) + '</strong> — ' + escapeHtml(_laT('pass1Extracting')) + '</div>'
|
||
+ '</div>'
|
||
+ '<ol id="laAddonIssues" class="la-issues" style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:1rem;"></ol>';
|
||
containerEl.parentNode.appendChild(section);
|
||
|
||
const issueListEl = section.querySelector('#laAddonIssues');
|
||
const pipelineEl = section.querySelector('.la-pipeline');
|
||
const issueCards = {};
|
||
|
||
const payload = {
|
||
text: text,
|
||
language: lang || window.DBN_CURRENT_LANG || 'no',
|
||
doc_type: 'auto',
|
||
source_tool: sourceTool || 'addon',
|
||
};
|
||
|
||
fetch('api/legal-analysis.php', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
}).then(async function (resp) {
|
||
if (!resp.ok || !resp.body) throw new Error('Server returned ' + resp.status);
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
while (true) {
|
||
const _r = await reader.read();
|
||
if (_r.done) break;
|
||
buffer += decoder.decode(_r.value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
let data;
|
||
try { data = JSON.parse(trimmed); } catch (_) { continue; }
|
||
laAddonEvent(data, issueListEl, pipelineEl, issueCards);
|
||
}
|
||
}
|
||
}).catch(function (err) {
|
||
section.innerHTML += '<p style="color:#b91c1c;margin-top:0.5rem;">' + escapeHtml(_laT('errorPrefix')) + ' ' + escapeHtml(err.message || String(err)) + '</p>';
|
||
});
|
||
}
|
||
|
||
function laAddonEvent(data, listEl, pipelineEl, cards) {
|
||
if (data.event === 'issues_extracted') {
|
||
listEl.innerHTML = '';
|
||
(data.issues || []).forEach(function (issue) {
|
||
const li = document.createElement('li');
|
||
li.className = 'la-issue pending';
|
||
li.dataset.issueId = String(issue.id);
|
||
const sev = issue.severity_hint || 'medium';
|
||
li.innerHTML =
|
||
'<div class="la-issue__head"><span class="la-issue__num">#' + issue.id + '</span>'
|
||
+ '<span class="la-severity la-severity-' + escapeHtml(sev) + '">' + escapeHtml(sev.toUpperCase()) + '</span></div>'
|
||
+ '<h4 class="la-issue__q">' + escapeHtml(issue.question || '') + '</h4>'
|
||
+ (issue.brief_context ? '<p class="la-issue__ctx"><em>' + escapeHtml(issue.brief_context) + '</em></p>' : '')
|
||
+ '<div class="la-issue__status">' + escapeHtml(_laT('waiting')) + '</div>';
|
||
listEl.appendChild(li);
|
||
cards[issue.id] = li;
|
||
});
|
||
pipelineEl.innerHTML =
|
||
'<div class="la-step done"><strong>' + escapeHtml(_laT('pass1')) + '</strong> — ' + escapeHtml(_laT('pass1Found', { n: (data.issues || []).length })) + '</div>'
|
||
+ '<div class="la-step running"><strong>' + escapeHtml(_laT('pass2')) + '</strong> — ' + escapeHtml(_laT('pass2Asking')) + '</div>';
|
||
} else if (data.event === 'progress') {
|
||
const card = cards[data.issue_id];
|
||
if (card) {
|
||
const status = card.querySelector('.la-issue__status');
|
||
if (status) {
|
||
status.textContent = data.step === 'issue_searching_corpus'
|
||
? _laT('searchingCorpus')
|
||
: _laT('askingFinetune');
|
||
}
|
||
card.classList.add('running');
|
||
}
|
||
} else if (data.event === 'issue_answered' && data.issue) {
|
||
const iss = data.issue;
|
||
const card = cards[iss.id];
|
||
if (!card) return;
|
||
card.classList.remove('pending', 'running');
|
||
card.classList.add('answered');
|
||
const sev = iss.severity || 'medium';
|
||
const sevEl = card.querySelector('.la-severity');
|
||
if (sevEl) {
|
||
sevEl.className = 'la-severity la-severity-' + escapeHtml(sev);
|
||
sevEl.textContent = escapeHtml(sev.toUpperCase());
|
||
}
|
||
const statusEl = card.querySelector('.la-issue__status');
|
||
if (statusEl) statusEl.remove();
|
||
let html = '<div class="la-issue__answer"><h5>' + escapeHtml(_laT('answerHeader')) + '</h5><p>' + escapeHtml(iss.answer || '').replace(/\n/g, '<br>') + '</p></div>';
|
||
if (iss.legal_basis) html += '<div class="la-issue__basis"><strong>' + escapeHtml(_laT('legalBasis')) + '</strong> ' + escapeHtml(iss.legal_basis) + '</div>';
|
||
if (iss.what_to_check) html += '<p class="la-issue__check"><em>' + escapeHtml(iss.what_to_check) + '</em></p>';
|
||
card.insertAdjacentHTML('beforeend', html);
|
||
} else if (data.event === 'final' && data.result) {
|
||
const res = data.result;
|
||
if (typeof res.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
|
||
window.dbnUpdateCredits(res.balance);
|
||
}
|
||
pipelineEl.innerHTML =
|
||
'<div class="la-step done"><strong>' + escapeHtml(_laT('pass1')) + '</strong> — ' + escapeHtml(_laT('pass1Found', { n: (res.issues || []).length })) + '</div>'
|
||
+ '<div class="la-step done"><strong>' + escapeHtml(_laT('pass2')) + '</strong> — ' + escapeHtml(_laT('pass2Answered', { n: (res.issues || []).length })) + '</div>'
|
||
+ '<div class="la-step done"><strong>' + escapeHtml(_laT('pass3')) + '</strong> — ' + escapeHtml(_laT('pass3Synthesis')) + '</div>';
|
||
if (res.overall_assessment) {
|
||
let syn = '<section class="la-synthesis" style="margin-bottom:1rem;padding:0.85rem 1.1rem;border-left:4px solid #0f766e;background:#ecfeff;border-radius:6px;">'
|
||
+ '<h3 style="margin-top:0;color:#0e7490;">' + escapeHtml(_laT('overall')) + '</h3>'
|
||
+ '<p>' + escapeHtml(res.overall_assessment) + '</p>';
|
||
if (Array.isArray(res.next_steps) && res.next_steps.length) {
|
||
syn += '<h4 style="margin:0.7rem 0 0.3rem;color:#155e75;">' + escapeHtml(_laT('nextSteps')) + '</h4><ul>'
|
||
+ res.next_steps.map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('')
|
||
+ '</ul>';
|
||
}
|
||
if (res.disclaimer) {
|
||
syn += '<p style="font-size:0.85rem;color:#64748b;margin-top:0.5rem;"><em>' + escapeHtml(res.disclaimer) + '</em></p>';
|
||
}
|
||
syn += '</section>';
|
||
pipelineEl.insertAdjacentHTML('afterend', syn);
|
||
}
|
||
// Save button (legal-analysis result is itself a saveable run)
|
||
if (typeof window.dbnShowSaveResultButton === 'function') {
|
||
const containerSection = pipelineEl.parentNode;
|
||
window.dbnShowSaveResultButton(
|
||
'legal-analysis',
|
||
{ text: '', language: window.DBN_CURRENT_LANG || 'no', doc_type: 'auto', source_tool: 'addon' },
|
||
res,
|
||
{ model: res.model || 'dbn-legal-agent-v3', latency_ms: res.latency_ms || 0 },
|
||
containerSection
|
||
);
|
||
}
|
||
} else if (data.event === 'error') {
|
||
pipelineEl.innerHTML += '<div style="color:#b91c1c;margin-top:0.4rem;">' + escapeHtml(_laT('errorPrefix')) + ' ' + escapeHtml(data.message || data.error || 'unknown') + '</div>';
|
||
}
|
||
}
|
||
|
||
function dbnInjectLegalAnalysisButton(text, lang, sourceTool, containerEl, options) {
|
||
if (!containerEl || !text || text.length < 80) return;
|
||
if (containerEl.querySelector('.la-addon-btn')) return;
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'la-addon-btn';
|
||
btn.textContent = (options && options.label) || _laT('addonButton');
|
||
btn.style.cssText = 'display:block;margin:1rem auto 0;padding:0.6rem 1.2rem;font-size:0.92rem;'
|
||
+ 'background:#0f766e;color:#fff;border:none;border-radius:6px;cursor:pointer;'
|
||
+ 'font-weight:600;letter-spacing:0.02em;';
|
||
btn.addEventListener('mouseenter', function () { btn.style.background = '#0d5e57'; });
|
||
btn.addEventListener('mouseleave', function () { btn.style.background = '#0f766e'; });
|
||
btn.addEventListener('click', function () {
|
||
btn.disabled = true;
|
||
btn.textContent = _laT('addonButtonBusy');
|
||
btn.style.background = '#94a3b8';
|
||
dbnRunLegalAnalysisAddon(text, lang || window.DBN_CURRENT_LANG || 'no', sourceTool, containerEl);
|
||
});
|
||
containerEl.appendChild(btn);
|
||
}
|
||
|
||
window.dbnRunLegalAnalysisAddon = dbnRunLegalAnalysisAddon;
|
||
window.dbnInjectLegalAnalysisButton = dbnInjectLegalAnalysisButton;
|
||
|
||
let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;
|
||
|
||
function dbnUpdateCredits(balance) {
|
||
if (typeof balance !== 'number' || balance < 0) return;
|
||
_freeTierBalance = balance;
|
||
const badge = document.getElementById('creditBadge');
|
||
if (!badge) return;
|
||
badge.textContent = '🪙 ' + balance + ' credit' + (balance !== 1 ? 's' : '');
|
||
badge.classList.toggle('is-low', balance > 0 && balance <= 2);
|
||
badge.classList.toggle('is-empty', balance === 0);
|
||
}
|
||
|
||
// Exposed so barnevernet.js / deep-research.js can call it
|
||
window.dbnUpdateCredits = dbnUpdateCredits;
|
||
|
||
function dbnFreeTierError(status, data) {
|
||
if (status === 429) {
|
||
const toast = document.createElement('div');
|
||
toast.className = 'credit-toast';
|
||
toast.textContent = 'Rate limit reached — you can make up to 10 requests per hour on the free tier.';
|
||
toast.textContent = data?.error?.message || 'Rate limit reached. Try again shortly or see pricing for higher caps.';
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 5000);
|
||
return;
|
||
}
|
||
// 402 — no credits
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'credit-modal-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="credit-modal" role="dialog" aria-modal="true" aria-labelledby="cmTitle">
|
||
<div class="credit-modal__icon">🪙</div>
|
||
<h3 id="cmTitle">Free credits used up</h3>
|
||
<p>Your 10 free credits for this month have been used.<br>
|
||
Credits reset on the 1st of next month — or upgrade to CaveauAI for unlimited access.</p>
|
||
<div class="credit-modal__actions">
|
||
<a href="https://dobetternorge.no/tools/" class="credit-modal__cta">Learn about CaveauAI</a>
|
||
<button class="credit-modal__dismiss" onclick="this.closest('.credit-modal-overlay').remove()">Close</button>
|
||
</div>
|
||
</div>`;
|
||
overlay.querySelector('.credit-modal__icon').textContent = 'DBN';
|
||
overlay.querySelector('#cmTitle').textContent = 'No credits remaining';
|
||
overlay.querySelector('p').innerHTML = 'Your monthly and prepaid credits have been used.<br>Monthly credits reset next month, and prepaid top-ups never expire.';
|
||
const pricingLink = overlay.querySelector('.credit-modal__cta');
|
||
pricingLink.setAttribute('href', '/pricing.php');
|
||
pricingLink.textContent = 'See plans and top-ups';
|
||
document.body.appendChild(overlay);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||
}
|
||
|
||
// Exposed so barnevernet.js / deep-research.js can call it
|
||
window.dbnFreeTierError = dbnFreeTierError;
|
||
|
||
// Inject credit badge into topbar on page load
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
if (_freeTierBalance < 0) return;
|
||
const actions = document.querySelector('.topbar-actions');
|
||
if (!actions) return;
|
||
const badge = document.createElement('span');
|
||
badge.id = 'creditBadge';
|
||
badge.className = 'credit-badge';
|
||
badge.title = 'Free monthly credits remaining';
|
||
dbnUpdateCredits(_freeTierBalance); // set initial state before inserting
|
||
badge.textContent = '🪙 ' + _freeTierBalance + ' credit' + (_freeTierBalance !== 1 ? 's' : '');
|
||
badge.classList.toggle('is-low', _freeTierBalance > 0 && _freeTierBalance <= 2);
|
||
badge.classList.toggle('is-empty', _freeTierBalance === 0);
|
||
actions.insertBefore(badge, actions.firstChild);
|
||
});
|