timeline: remove GPU, add SSE status updates, DOCX export, single-file, engine-aware credits

- Remove GPU/cuttlefish engine from timeline.php, api/timeline.php, LegalTools.php, tools.js (all 4 langs)
- Add engine-aware credit cost: gpt-4o-mini=1 credit, gpt-4o=2 credits (matches redact pattern)
- Remove multiple attribute from file input (single document only)
- New api/timeline-stream.php: SSE endpoint emitting status events + final result
- New api/timeline-download.php: DOCX export of timeline events
- LegalTools::timeline() gains ?callable $onProgress for live status updates
- tools.js: spinner on run, SSE streaming fetch, Export to Word button
- Save to My Docs was already wired (showSaveResultButton at line 1136)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 09:32:28 +02:00
parent 4b8b675a64
commit d47024ed67
7 changed files with 406 additions and 43 deletions
+86 -16
View File
@@ -223,8 +223,7 @@ const TIMELINE_I18N = {
timelineEngine: 'Engine',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineGpu: 'GPU (cuttlefish)',
timelineEngineHint: 'Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on cuttlefish.',
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',
@@ -240,7 +239,7 @@ const TIMELINE_I18N = {
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 up to 5 files here, or',
timelineUploadDrop: 'Drop a file here, or',
timelineUploadBrowse: 'browse',
timelineUploadHint: 'text extracted in memory, never stored',
timelineUploadClear: '× Clear',
@@ -265,8 +264,7 @@ const TIMELINE_I18N = {
timelineEngine: 'Motor',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineGpu: 'GPU (cuttlefish)',
timelineEngineHint: 'Azure-motorer bruker BNL Azure-kreditter. GPU kjører lokal LiteLLM-proxy på cuttlefish.',
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',
@@ -282,7 +280,7 @@ const TIMELINE_I18N = {
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 opptil 5 filer her, eller',
timelineUploadDrop: 'Slipp en fil her, eller',
timelineUploadBrowse: 'bla',
timelineUploadHint: 'tekst hentes i minnet, lagres aldri',
timelineUploadClear: '× Tøm',
@@ -307,8 +305,7 @@ const TIMELINE_I18N = {
timelineEngine: 'Рушій',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineGpu: 'GPU (cuttlefish)',
timelineEngineHint: 'Рушії Azure використовують кредити BNL Azure. GPU запускає локальний проксі LiteLLM на cuttlefish.',
timelineEngineHint: 'gpt-4o-mini: 1 кредит — швидко, добре справляється з більшістю хронологій. gpt-4o: 2 кредити — вища точність для складних справ з багатьма учасниками.',
timelineAdvancedToggle: 'Розширені налаштування',
timelineFocus: 'Фокус',
timelineFocusAll: 'Всі події',
@@ -324,7 +321,7 @@ const TIMELINE_I18N = {
timelineIncludeRelative: 'Включити відносні / повторювані дати',
timelineDatesHint: 'Якщо позначено, відносні посилання ("три тижні потому", "щопонеділка") включаються. Зніміть, щоб повертати лише точні календарні дати.',
timelineUploadAria: 'Завантаження файлів',
timelineUploadDrop: 'Перетягніть до 5 файлів сюди, або',
timelineUploadDrop: 'Перетягніть один файл сюди, або',
timelineUploadBrowse: 'огляд',
timelineUploadHint: 'текст обробляється в памʼяті, ніколи не зберігається',
timelineUploadClear: '× Очистити',
@@ -349,8 +346,7 @@ const TIMELINE_I18N = {
timelineEngine: 'Silnik',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineGpu: 'GPU (cuttlefish)',
timelineEngineHint: 'Silniki Azure używają kredytów Azure BNL. GPU korzysta z lokalnego proxy LiteLLM na cuttlefish.',
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',
@@ -366,7 +362,7 @@ const TIMELINE_I18N = {
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ść do 5 plików tutaj lub',
timelineUploadDrop: 'Upuść jeden plik tutaj lub',
timelineUploadBrowse: 'przeglądaj',
timelineUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany',
timelineUploadClear: '× Wyczyść',
@@ -391,6 +387,7 @@ const TIMELINE_I18N = {
let lastTimelineEvents = [];
let lastTimelineEventsOriginal = [];
let lastTimelineWhatWeFound = '';
let activeActorFilters = new Set();
let timelineSearchTerm = '';
let showSources = true;
@@ -957,6 +954,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
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();
@@ -1121,12 +1119,53 @@ async function runTool(event) {
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') {
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></div>';
}
renderTrace([
{ label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
]);
try {
const data = await postJson(tool.endpoint, payload);
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.');
}
@@ -1562,13 +1601,14 @@ function renderMainFinding(data) {
if (data.tool === 'timeline') {
lastTimelineEventsOriginal = data.events || [];
lastTimelineEvents = [...lastTimelineEventsOriginal];
lastTimelineWhatWeFound = data.what_we_found || '';
activeActorFilters = new Set();
timelineSearchTerm = '';
showSources = true;
timelineSortMode = 'doc';
const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV';
const csvBtn = lastTimelineEventsOriginal.length
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button></div>`
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);
@@ -1579,7 +1619,7 @@ function renderMainFinding(data) {
<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(data.what_we_found || '')}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${csvBtn}`;
return `<p>${escapeHtml(lastTimelineWhatWeFound)}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${exportRow}`;
}
if (data.tool === 'summarize') {
return [
@@ -1809,6 +1849,36 @@ function exportTimelineCSV(events) {
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';