Dateien
AegisSight-Monitor/src/static/js/studio.js
claude-dev ee31702015 feat(studio): Phase 3 - Fall-Chat (RAG) mit Guardrails, Timeline, Recherche-Angebot
Fall-Chat und Ereignis-Timeline, alles ueber Claude, strikt fallorientiert.

incidents.py:
- GET /{id}/events: Aktivitaets-Timeline (article_ingest/refresh/analysis/chat_qa/source_change)
- POST /{id}/ask: Fall-Chat/RAG mit Guardrails:
  * tools=None (kein Netz/kein Werkzeug) = zentrale Anti-Exfil-Abwehr (GitLost)
  * nur Materialien DIESES Falls (scope=alle entfaellt), [n]-Zitate, strikt aus Materialien
  * Claude-Vorauswahl der relevanten Artikel statt lokalem Embedding (kein lokales Modell)
  * _escape_prompt_content + Ausgabe-Leak-Filter + EchoLeak-Haertung (externe Bilder/Links/URLs raus)
  * Verbotsliste: kein Backend/App/Modell/Anbieter, kein Fremd-Fall
  * chat_qa in incident_events protokolliert
- Recherche-Angebot: liefert die Materialien die Frage nicht, haengt der Analyst einen
  needs_research-Block an (focus + description_addition, NUR aus der Frage abgeleitet).
- POST /{id}/clarify (nur auf Nutzer-Bestaetigung): Beschreibung ergaenzen + fokussierte
  Folge-Recherche AUSSCHLIESSLICH zur Frage (Researcher title=focus/description=frage),
  url-dedupliziert in den Fall. Laeuft als Hintergrund-Job (stage_runners.start_job).

stage_runners.py: generischer start_job() fuer Hintergrund-Jobs (run-status-sichtbar).
api.js: clarify(). studio.js + studio.css: Angebot rendern, bestaetigen, run-status pollen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 23:34:52 +02:00

2089 Zeilen
101 KiB
JavaScript

/**
* AegisSight Studio - NotebookLM-artiger Controller.
*
* Wiederverwendet die reinen Render-Funktionen aus components.js (UI.*) und den
* API-Client (API.*). Das globale `App`-Shim stellt nur die wenigen Felder/Handler
* bereit, die einige geliehene Render-Funktionen per onclick erwarten.
*/
/* --- App-Shim (Minimal-Kompatibilitaet für components.js) --- */
const App = {
currentIncidentId: null,
_fcHidden: new Set(),
// Faktencheck-Statusfilter (von UI.renderFactCheckFilters referenziert)
toggleFactCheckFilter(status) {
if (this._fcHidden.has(status)) this._fcHidden.delete(status);
else this._fcHidden.add(status);
document.querySelectorAll('#art-fc-body .factcheck-item').forEach(el => {
el.style.display = this._fcHidden.has(el.dataset.fcStatus) ? 'none' : '';
});
},
toggleFcDropdown(event) {
if (event) event.stopPropagation();
const menu = document.getElementById('fc-dropdown-menu');
if (menu) menu.classList.toggle('open');
},
// Stubs (von manchen Render-Funktionen referenziert, hier nicht genutzt)
toggleSourceOverviewDetail() {},
syncRefreshStatus() { Studio.syncRefreshStatus(); },
};
/* --- Studio-Controller --- */
const Studio = {
incidents: [],
incident: null,
articles: [],
sourcesMeta: [], // Cache von API.listSources() für Badges
selected: null, // Set aktiver Quellen-Namen; null = alle aktiv
convId: null,
_startTime: null,
_timerInt: null,
openTabs: [], // in der Mitte geöffnete Studio-Teile (Reihenfolge)
activeTab: null, // aktuell sichtbarer Tab
_closeSvg: '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
// Ereignistypen der Aktivitaets-Timeline: Label + Lucide-Icon
_evMeta: {
article_ingest: { label: 'Meldung', icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>' },
refresh: { label: 'Aktualisierung', icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/></svg>' },
analysis: { label: 'Analyse', icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>' },
chat_qa: { label: 'Frage & Antwort', icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' },
source_change: { label: 'Quelle', icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v8"/><path d="M8 12h8"/></svg>' },
},
// Untersektionen der Timeline (Reihenfolge + Sektions-Ueberschrift)
_evSections: [
{ key: 'chat_qa', label: 'Konversation' },
{ key: 'source_change', label: 'Quellen' },
{ key: 'refresh', label: 'Aktualisierungen' },
{ key: 'analysis', label: 'Analyse' },
{ key: 'article_ingest', label: 'Meldungen' },
],
caseFilter: '',
_archiveOpen: false,
caseSel: new Set(), // Mehrfachauswahl in der Fall-Liste (IDs)
caseMode: null, // null = normale Liste, 'archive' | 'delete' = Auswahlmodus
async init() {
if (!localStorage.getItem('osint_token')) { window.location.href = '/'; return; }
try {
const me = await API.getMe();
// Studio ist vorerst nur fuer info@aegis-sight.de freigegeben.
if (!me || me.email !== 'info@aegis-sight.de') { window.location.href = '/dashboard'; return; }
} catch (e) { /* 401 leitet in api.js um */ }
WS.connect();
this._wireWs();
this._initDivider();
this._initTabDnD();
await this.loadIncidents();
const last = localStorage.getItem('studio_incident');
if (last && this.incidents.some(i => String(i.id) === String(last))) {
this.selectIncident(last);
} else {
// Ohne offenen Fall zeigt die linke Spalte die Fall-Auswahl
this._showEmpty(true);
this.leftTab('cases');
}
},
/**
* Ohne offenen Fall bleiben Mitte und rechte Spalte leer - die LINKE Spalte
* muss aber stehen bleiben, sonst käme man an die Fall-Auswahl nicht heran.
*/
_showEmpty(on) {
// Mitte + rechte Spalte bleiben stehen, werden aber ausgegraut und
// unbedienbar (Klasse 'idle'). Nur der Hinweis legt sich darueber.
const cols = document.getElementById('studio-cols');
if (cols) cols.classList.toggle('idle', on);
document.getElementById('studio-empty').style.display = on ? '' : 'none';
},
// === Linke Spalte: Reiter "Fälle" / "Quellen" ===
leftTab(which) {
const cases = which === 'cases';
// Beim Verlassen der Fall-Liste den Auswahlmodus nicht offen lassen
if (!cases && this.caseMode !== null) this.endCaseMode();
document.getElementById('pane-cases').hidden = !cases;
document.getElementById('pane-sources').hidden = cases;
const tc = document.getElementById('lt-cases');
const ts = document.getElementById('lt-sources');
tc.classList.toggle('active', cases);
ts.classList.toggle('active', !cases);
tc.setAttribute('aria-selected', String(cases));
ts.setAttribute('aria-selected', String(!cases));
},
// === Fall-Liste (früher das Dropdown in der Kopfzeile) ===
async loadIncidents() {
try {
this.incidents = await API.listIncidents() || [];
} catch (e) {
UI.showToast('Lagen konnten nicht geladen werden', 'error');
this.incidents = [];
}
this.renderCases();
},
filterCases(q) {
this.caseFilter = (q || '').trim().toLowerCase();
this.renderCases();
},
toggleArchive() {
this._archiveOpen = !this._archiveOpen;
this.renderCases();
},
// Fälle, die der aktuelle Filter zeigt (Archiv nur, wenn aufgeklappt) - genau
// diese Menge trifft "Alle auswählen".
_visibleCases() {
const q = this.caseFilter;
const match = (i) => !q || (i.title || '').toLowerCase().includes(q);
return this.incidents.filter(i => match(i) && (i.status === 'active' || this._archiveOpen));
},
renderCases() {
const list = document.getElementById('cases-list');
if (!list) return;
const q = this.caseFilter;
const match = (i) => !q || (i.title || '').toLowerCase().includes(q);
const all = this.incidents.filter(match);
const cur = this.incident ? String(this.incident.id) : null;
const groups = [
{ key: 'live', label: 'Live-Monitoring', items: all.filter(i => i.type !== 'research' && i.status === 'active') },
{ key: 'research', label: 'Recherchen', items: all.filter(i => i.type === 'research' && i.status === 'active') },
];
const archived = all.filter(i => i.status !== 'active');
// Haken und Kontextmenue schließen sich aus: im Auswahlmodus häkelt man,
// sonst öffnet ein Klick den Fall.
const sel = this.caseMode !== null;
const item = (i) => `
<div class="case-item${String(i.id) === cur ? ' active' : ''}${this.caseSel.has(i.id) ? ' picked' : ''}" data-id="${i.id}">
${sel ? `<label class="case-check" title="Auswählen">
<input type="checkbox" ${this.caseSel.has(i.id) ? 'checked' : ''}
onchange="Studio.toggleCaseSel(${i.id}, this.checked)">
</label>` : ''}
<button class="case-open" onclick="${sel ? `Studio.toggleCaseSel(${i.id}, !Studio.caseSel.has(${i.id}))` : `Studio.selectIncident(${i.id})`}" title="${UI.escape(i.title)}">
<span class="case-title">${UI.escape(i.title)}</span>
<span class="case-count">${i.article_count}</span>
</button>
${sel ? '' : `<button class="case-menu-btn" onclick="Studio.caseMenu(event, ${i.id})" title="Mehr" aria-label="Mehr">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>`}
</div>`;
let html = '';
groups.forEach(g => {
if (!g.items.length) return;
html += `<div class="case-group">${g.label} <span>${g.items.length}</span></div>`;
html += g.items.map(item).join('');
});
if (archived.length) {
html += `<button class="case-group case-group-toggle${this._archiveOpen ? ' open' : ''}" onclick="Studio.toggleArchive()">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
Archiv <span>${archived.length}</span>
</button>`;
if (this._archiveOpen) html += archived.map(item).join('');
}
list.innerHTML = html; // ohne Treffer bleibt die Liste bewusst leer (kein Hinweistext)
const cnt = document.getElementById('cases-count');
if (cnt) cnt.textContent = this.incidents.filter(i => i.status === 'active').length || '';
this._renderBulkBar();
},
/**
* Rueckfrage im Studio-Design statt Browser-confirm(). Gibt ein Promise<boolean>.
* (Das native confirm sieht nach Browser-Warnung aus und blockiert die Seite.)
*/
_confirmResolve: null,
askConfirm({ title, body, okLabel = 'Löschen', danger = true }) {
return new Promise((resolve) => {
this._confirmResolve = resolve;
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-body').innerHTML = body;
const ok = document.getElementById('confirm-ok');
ok.textContent = okLabel;
ok.className = 'btn ' + (danger ? 'btn-danger' : 'btn-primary');
document.getElementById('modal-confirm').classList.add('active');
setTimeout(() => document.getElementById('confirm-cancel').focus(), 50);
});
},
_confirmClose(answer) {
document.getElementById('modal-confirm').classList.remove('active');
const r = this._confirmResolve;
this._confirmResolve = null;
if (r) r(answer);
},
/* === Startpanel: der frisch angelegte Fall fragt aktiv nach ===
Früher stand der Nutzer nach dem Anlegen vor einer leeren Oberflaeche und
musste die Pipeline kennen, um irgendetwas auszuloesen. Jetzt legt das Studio
die Wege offen hin - "später entscheiden" ist einer davon, aber eine bewusste
Wahl. Ein Fall, für den schon einmal gewählt wurde, fragt nicht erneut. */
_renderStartPanel() {
const panel = document.getElementById('start-panel');
if (!panel) return;
const inc = this.incident;
const leer = inc && !(this.articles || []).length && !((inc.summary || '').trim());
const schonGewaehlt = inc && localStorage.getItem('studio_start_' + inc.id);
const zeigen = !!leer && !this._runningStage && !schonGewaehlt;
panel.hidden = !zeigen;
// "Kompletter Lauf" rechts und die Startfrage sind dieselbe Aktion - nie beide
// gleichzeitig zeigen. Wird die Frage nicht (mehr) gestellt, ist der Knopf wieder
// da; sonst stünde man nach einem ergebnislosen Lauf ohne Startmoeglichkeit da.
const runAll = document.querySelector('.run-all');
if (runAll) runAll.hidden = zeigen;
if (!zeigen) return;
// Texte an den Lage-Typ anpassen: eine Recherche läuft beim ersten Mal
// mit drei Durchläufen und dauert entsprechend laenger.
const research = inc.type === 'research';
const full = document.getElementById('sp-full-desc');
if (full) {
full.textContent = research
? 'Tiefenrecherche in drei Durchläufen (Breite Erfassung, Vertiefung, Konsolidierung), danach Analyse und Faktencheck. Dauert deutlich länger als ein einzelner Lauf.'
: 'Sammeln, Analyse, Faktencheck und Karte nacheinander. Am Ende steht ein fertiges Lagebild.';
}
const auto = document.getElementById('sp-auto-desc');
if (auto) {
auto.textContent = inc.refresh_mode === 'auto'
? 'Ist für diesen Fall bereits eingeschaltet - der erste Lauf startet von selbst innerhalb einer Minute.'
: `Der Fall läuft ab jetzt selbstständig (alle ${inc.refresh_interval || 15} Minuten). Der erste Lauf beginnt sofort.`;
}
this._checkSearchStatus();
},
// Ehrlich ansagen, wenn ein Lauf gerade kaum etwas bringen kann
async _checkSearchStatus() {
const box = document.getElementById('sp-warn');
const txt = document.getElementById('sp-warn-text');
if (!box) return;
let st = null;
try { st = await API.getSearchStatus(); } catch (e) { return; }
const blind = !st || !st.reachable || !st.search_enabled;
box.hidden = !blind;
if (blind && txt) {
txt.textContent = (!st || !st.reachable)
? 'Die Websuche antwortet gerade nicht (Suchdienst nicht erreichbar). Ein Lauf bringt derzeit nur Treffer aus den RSS-Feeds.'
: 'Die Websuche ist abgeschaltet, weil kein mobiler Ausgang steht. Ein Lauf bringt derzeit nur Treffer aus den RSS-Feeds.';
}
},
async startChoice(kind) {
const panel = document.getElementById('start-panel');
const inc = this.incident;
if (!inc) return;
// Eine Wahl -> dieser Fall fragt nicht noch einmal, und der Startknopf rechts
// kommt zurück (beide sind dieselbe Aktion).
localStorage.setItem('studio_start_' + inc.id, kind);
panel.hidden = true;
const runAll = document.querySelector('.run-all');
if (runAll) runAll.hidden = false;
if (kind === 'ingest') {
this.leftTab('sources');
const ip = document.getElementById('ingest-panel');
if (ip && ip.hidden) this.toggleIngest();
document.getElementById('ingest-add-btn').focus();
return;
}
if (kind === 'auto') {
try {
await API.updateIncident(inc.id, { refresh_mode: 'auto' });
inc.refresh_mode = 'auto';
await this.loadIncidents();
UI.showToast('Automatische Aktualisierung eingeschaltet - der erste Lauf startet gleich.', 'success');
} catch (e) {
UI.showToast((e && e.message) || 'Umstellen fehlgeschlagen', 'error');
localStorage.removeItem('studio_start_' + inc.id);
panel.hidden = false;
}
return;
}
// 'full' (kompletter Lauf) oder 'collect' (nur sammeln)
this.runStage(kind);
},
// === Auswahlmodus + Massenaktionen ===
// Die Haken sind normalerweise WEG. Erst der Knopf "Archivieren" bzw. "Löschen"
// unten schaltet den Auswahlmodus ein und blendet sie ein.
startCaseMode(mode) {
this.caseMode = mode;
this.caseSel.clear();
this.renderCases();
},
endCaseMode() {
this.caseMode = null;
this.caseSel.clear();
const sa = document.getElementById('case-select-all');
if (sa) sa.checked = false;
this.renderCases();
},
toggleCaseSel(id, on) {
if (on) this.caseSel.add(id); else this.caseSel.delete(id);
this.renderCases();
},
selectAllCases(on) {
const vis = this._visibleCases();
if (on) vis.forEach(i => this.caseSel.add(i.id));
else vis.forEach(i => this.caseSel.delete(i.id));
this.renderCases();
},
_renderBulkBar() {
const mode = this.caseMode;
const n = this.caseSel.size;
// Standardleiste (zwei Startknoepfe) vs. Auswahlmodus (bestätigen/abbrechen)
const tools = document.getElementById('case-tools');
const bar = document.getElementById('bulk-bar');
const row = document.getElementById('case-select-row');
if (tools) tools.hidden = mode !== null;
if (bar) bar.hidden = mode === null;
if (row) row.hidden = mode === null;
if (mode === null) return;
const cnt = document.getElementById('bulk-count');
if (cnt) {
cnt.textContent = n === 0
? (mode === 'delete' ? 'Fälle zum Löschen anhaken' : 'Fälle zum Archivieren anhaken')
: (n === 1 ? '1 Fall ausgewählt' : `${n} Fälle ausgewählt`);
}
// Nur anbieten, was zum Modus UND zur Auswahl passt
const sel = this.incidents.filter(i => this.caseSel.has(i.id));
const anyActive = sel.some(i => i.status === 'active');
const anyArchived = sel.some(i => i.status !== 'active');
const ba = document.getElementById('bulk-archive');
const bv = document.getElementById('bulk-activate');
const bd = document.getElementById('bulk-delete');
if (ba) { ba.hidden = mode !== 'archive' || !anyActive; ba.textContent = `Archivieren (${sel.filter(i => i.status === 'active').length})`; }
if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; bv.textContent = `Aktivieren (${sel.filter(i => i.status !== 'active').length})`; }
if (bd) { bd.hidden = mode !== 'delete' || n === 0; bd.textContent = `Löschen (${n})`; }
// "Alle"-Haken spiegelt, ob die sichtbare Menge komplett gewählt ist
const vis = this._visibleCases();
const sa = document.getElementById('case-select-all');
if (sa) sa.checked = vis.length > 0 && vis.every(i => this.caseSel.has(i.id));
const hint = document.getElementById('case-sel-hint');
if (hint) hint.textContent = n ? `${n} ausgewählt` : '';
},
// Massenaktionen laufen sequentiell über die normalen Endpunkte (mit deren
// Rechte-Pruefung). Fehlschlaege werden gezählt statt still verschluckt.
async _bulkRun(ids, fn, verb) {
let ok = 0;
const failed = [];
for (const id of ids) {
try { await fn(id); ok++; }
catch (e) { failed.push(id); }
}
this.caseSel.clear();
this.caseMode = null; // Auswahlmodus verlassen -> Haken verschwinden wieder
await this.loadIncidents();
if (failed.length) {
UI.showToast(`${ok} ${verb}, ${failed.length} fehlgeschlagen (IDs ${failed.join(', ')})`, 'error');
} else {
UI.showToast(`${ok} ${ok === 1 ? 'Fall' : 'Fälle'} ${verb}`, 'success');
}
},
async bulkStatus(status) {
const ids = this.incidents
.filter(i => this.caseSel.has(i.id) && (status === 'archived' ? i.status === 'active' : i.status !== 'active'))
.map(i => i.id);
if (!ids.length) return;
const verb = status === 'archived' ? 'archiviert' : 'wieder aktiviert';
await this._bulkRun(ids, (id) => API.updateIncident(id, { status }), verb);
},
async bulkDelete() {
const sel = this.incidents.filter(i => this.caseSel.has(i.id));
if (!sel.length) return;
const arts = sel.reduce((n, i) => n + (i.article_count || 0), 0);
const liste = sel.slice(0, 8).map(i => `<li>${UI.escape(i.title)}</li>`).join('')
+ (sel.length > 8 ? `<li class="cd-more">… und ${sel.length - 8} weitere</li>` : '');
const ok = await this.askConfirm({
title: `${sel.length} ${sel.length === 1 ? 'Fall' : 'Fälle'} endgültig löschen?`,
okLabel: `Löschen (${sel.length})`,
body: `<ul class="confirm-list">${liste}</ul>
<p class="confirm-warn">Damit gehen <strong>${arts} Artikel</strong> samt Faktenchecks,
Lageberichten und Karten verloren. Das lässt sich nicht rückgängig machen.</p>
<p class="confirm-hint">Wenn du sie nur aus der Liste nehmen willst, nutze stattdessen „Archivieren“.</p>`,
});
if (!ok) return;
const ids = sel.map(i => i.id);
const offen = this.incident && ids.includes(this.incident.id);
await this._bulkRun(ids, (id) => API.deleteIncident(id), 'gelöscht');
if (offen) {
// Der offene Fall war dabei -> zurück in die leere Ansicht
this.incident = null;
localStorage.removeItem('studio_incident');
this._showEmpty(true);
this.leftTab('cases');
document.getElementById('incident-title').textContent = '';
document.getElementById('type-badge').style.display = 'none';
}
},
// Kontextmenue je Fall: archivieren / reaktivieren / löschen
caseMenu(ev, id) {
ev.stopPropagation();
this._closeCaseMenu();
const inc = this.incidents.find(i => String(i.id) === String(id));
if (!inc) return;
const archived = inc.status !== 'active';
const m = document.createElement('div');
m.className = 'case-menu';
m.id = 'case-menu';
m.innerHTML = `
<button onclick="Studio.setCaseStatus(${id}, '${archived ? 'active' : 'archived'}')">
${archived ? 'Wieder aktivieren' : 'Archivieren'}
</button>
<button class="danger" onclick="Studio.deleteCase(${id})">Löschen …</button>`;
document.body.appendChild(m);
const r = ev.currentTarget.getBoundingClientRect();
m.style.top = `${Math.min(r.bottom + 4, window.innerHeight - m.offsetHeight - 8)}px`;
m.style.left = `${Math.min(r.left, window.innerWidth - m.offsetWidth - 8)}px`;
this._caseMenuCloser = () => this._closeCaseMenu();
setTimeout(() => document.addEventListener('click', this._caseMenuCloser, { once: true }), 0);
},
_closeCaseMenu() {
const m = document.getElementById('case-menu');
if (m) m.remove();
if (this._caseMenuCloser) {
document.removeEventListener('click', this._caseMenuCloser);
this._caseMenuCloser = null;
}
},
async setCaseStatus(id, status) {
this._closeCaseMenu();
try {
await API.updateIncident(id, { status });
await this.loadIncidents();
UI.showToast(status === 'archived' ? 'Fall archiviert' : 'Fall wieder aktiv', 'info');
} catch (e) {
UI.showToast((e && e.message) || 'Änderung fehlgeschlagen', 'error');
}
},
async deleteCase(id) {
this._closeCaseMenu();
const inc = this.incidents.find(i => String(i.id) === String(id));
const name = inc ? inc.title : 'dieser Fall';
const arts = inc ? (inc.article_count || 0) : 0;
const ok = await this.askConfirm({
title: 'Fall endgültig löschen?',
okLabel: 'Löschen',
body: `<ul class="confirm-list"><li>${UI.escape(name)}</li></ul>
<p class="confirm-warn">Damit gehen <strong>${arts} Artikel</strong> samt Faktenchecks,
Lageberichten und Karten verloren. Das lässt sich nicht rückgängig machen.</p>
<p class="confirm-hint">Wenn du ihn nur aus der Liste nehmen willst, nutze stattdessen „Archivieren“.</p>`,
});
if (!ok) return;
try {
await API.deleteIncident(id);
if (this.incident && String(this.incident.id) === String(id)) {
this.incident = null;
localStorage.removeItem('studio_incident');
this._showEmpty(true);
this.leftTab('cases');
document.getElementById('incident-title').textContent = '';
document.getElementById('type-badge').style.display = 'none';
}
await this.loadIncidents();
UI.showToast('Fall gelöscht', 'info');
} catch (e) {
UI.showToast((e && e.message) || 'Löschen fehlgeschlagen', 'error');
}
},
// === Auswahl + Detail laden ===
async selectIncident(id) {
id = parseInt(id);
if (!id) return;
App.currentIncidentId = id;
localStorage.setItem('studio_incident', id);
this._showEmpty(false);
this.selected = null;
this.convId = null;
const si = document.getElementById('src-search-input');
if (si) si.value = '';
// Fall gewählt -> es geht mit den Quellen weiter
this.leftTab('sources');
await this.loadDetail(id);
this.renderCases(); // aktiven Fall in der Liste hervorheben
},
async loadDetail(id) {
let inc, arts, fcs, locs, srcMeta, citeSrc, fresh;
try {
[inc, arts, fcs, locs, srcMeta, citeSrc, fresh] = await Promise.all([
API.getIncident(id),
API.getArticles(id, { limit: 500 }).catch(() => ({ articles: [] })),
API.getFactChecks(id).catch(() => []),
API.getLocations(id).catch(() => []),
this.sourcesMeta.length ? Promise.resolve(this.sourcesMeta) : API.listSources().catch(() => []),
API.getIncidentSources(id).catch(() => ({ sources: [] })),
API.getFreshness(id).catch(() => null),
]);
} catch (e) {
UI.showToast('Lage-Details konnten nicht geladen werden', 'error');
return;
}
this.fresh = fresh; // Datenstand je Artefakt (Grundlage der Veraltet-Anzeige)
this.incident = inc;
this.articles = Array.isArray(arts) ? arts : (arts.articles || []);
this.sourcesMeta = Array.isArray(srcMeta) ? srcMeta : [];
// /locations antwortet mit {locations, category_labels}, nicht mit einem Array.
// Ungeprüft weitergereicht wirft UI.renderMap (locations.reduce) und bricht
// loadDetail ab. Gleiche Normalisierung wie im Dashboard (app.js).
const locList = Array.isArray(locs) ? locs : ((locs && locs.locations) || []);
const catLabels = (locs && !Array.isArray(locs) && locs.category_labels) || null;
// Zitat-Quellen (getIncident liefert sources_json bewusst nicht mit)
this.citeSources = (citeSrc && citeSrc.sources) || [];
this.renderHeader(inc);
this.renderSources();
// Quellen-Ingest: Zustand je Fall zurücksetzen + laufende/fertige Uploads laden
this._stopUploadPoll();
this._seenDone = new Set();
this.uploads = [];
const ip = document.getElementById('ingest-panel'); if (ip) ip.hidden = true;
this.loadUploads();
this.renderArtifacts(inc, fcs, locList, catLabels);
this._stagesIdle(); // Karten freischalten + Datenstand anzeigen
this._resumeStage(); // läuft gerade ein Baustein? -> Karte zurück auf "läuft"
this._renderStartPanel(); // leerer Fall? -> aktiv nach dem nächsten Schritt fragen
this.renderChatIntro(inc);
// Snapshots lazy nachladen
this._snapLoaded = false;
// Studio-Tabs zurücksetzen und Lagebild als Standard-Tab öffnen
this._initTabs();
},
_sources(inc) {
// Zitat-Quellen aus /incidents/{id}/sources (getIncident liefert sie nicht mit)
return this.citeSources || [];
},
_isResearch() { return this.incident && this.incident.type === 'research'; },
renderHeader(inc) {
const badge = document.getElementById('type-badge');
const research = inc.type === 'research';
// Der Titel stand früher im Dropdown - ohne ihn wüsste man nicht, worin man arbeitet
const t = document.getElementById('incident-title');
if (t) {
t.textContent = inc.title || '';
t.title = inc.title || '';
}
badge.textContent = research ? 'Analyse' : 'Live';
badge.className = 'studio-type-badge' + (research ? ' type-research' : '');
badge.style.display = '';
document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild';
document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen';
},
// === Spalte 1: Quellen ===
_norm(s) { return (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim(); },
_domainOf(url) {
try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); }
catch (e) { return ''; }
},
_reg(dom) { return (dom || '').split('.').slice(-2).join('.'); },
_feedFor(sourceName, sampleUrl) {
// 1. Domain-Match (zuverlässig) — exakter Host, dann registrierbare Domain.
// KEIN Fuzzy-Namensmatch: "Bundestag" würde sonst fälschlich die
// Telegram-Quelle "AfD-Fraktion im Bundestag" (niedrig) treffen.
const dom = this._domainOf(sampleUrl);
if (dom) {
let f = this.sourcesMeta.find(s => s.domain && s.domain.toLowerCase() === dom);
if (f) return f;
const reg = this._reg(dom);
f = this.sourcesMeta.find(s => s.domain && this._reg(s.domain.toLowerCase()) === reg);
if (f) return f;
}
// 2. Exakter (normalisierter) Namensmatch als Fallback — kein Teilstring.
const n = this._norm(sourceName);
if (!n) return null;
return this.sourcesMeta.find(s => this._norm(s.name) === n) || null;
},
renderSources() {
const list = document.getElementById('sources-list');
// Fertige Uploads je Artikel -> Kategorie + Aktionen
const upByArt = {};
(this.uploads || []).forEach(u => { if (u.status === 'done' && u.article_id) upByArt[u.article_id] = u; });
// Artikel nach Quelle gruppieren; Quelle einer Kategorie zuordnen
// (reguläre/RSS-Quellen -> "webseiten", hinzugefügte Materialien -> ihre Kategorie)
const groups = {};
this.articles.forEach(a => {
const name = a.source || 'Unbekannt';
const up = upByArt[a.id];
if (!groups[name]) groups[name] = { name, arts: [], langs: new Set(), cat: 'webseiten' };
groups[name].arts.push(a);
groups[name].langs.add((a.language || 'de').toUpperCase());
if (up) groups[name].cat = up.category || 'dokumente';
});
const arr = Object.values(groups);
// Im Reiter ist nur Platz für die Zahl; das Ausführliche steckt im Tooltip
const sc = document.getElementById('sources-count');
sc.textContent = arr.length || '';
sc.title = `${this.articles.length} Artikel aus ${arr.length} Quellen`;
if (!arr.length) {
list.innerHTML = '<div class="empty-hint">Noch keine Artikel. Starte eine Aktualisierung.</div>';
return;
}
// nach Kategorie gliedern (feste Reihenfolge), innerhalb nach Artikelzahl
const order = ['webseiten', 'dokumente', 'bilder', 'sprachnachrichten'];
const byCat = {};
arr.forEach(g => { (byCat[g.cat] = byCat[g.cat] || []).push(g); });
let html = '';
order.filter(c => byCat[c]).forEach(cat => {
const meta = this._ingCat[cat] || { label: cat, icon: '' };
const gs = byCat[cat].sort((a, b) => b.arts.length - a.arts.length);
const nArts = gs.reduce((n, g) => n + g.arts.length, 0);
const collapsed = this._collapsedCats && this._collapsedCats.has(cat);
html += `<div class="src-cat${collapsed ? ' collapsed' : ''}" data-cat="${cat}">
<div class="src-cat-head" onclick="Studio.toggleCat('${cat}')">
<svg class="src-cat-chevron" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
${meta.icon}<span>${meta.label}</span><span class="src-cat-count">${nArts}</span>
</div>`;
html += gs.map(g => this._renderSourceGroup(g, upByArt)).join('');
html += `</div>`;
});
list.innerHTML = html;
},
_renderSourceGroup(g, upByArt) {
const feed = this._feedFor(g.name, g.arts[0] && g.arts[0].source_url);
const badges = feed ? UI._renderClassificationBadges(feed) : '';
const nameEsc = UI.escape(g.name);
const arts = g.arts
.slice()
.sort((a, b) => this._ts(b) - this._ts(a))
.slice(0, 30)
.map(a => {
const t = this._fmtDate(a);
const h = UI.escape(a.headline_de || a.headline || a.title || 'Ohne Titel');
const up = upByArt[a.id];
if (up && up.category !== 'webseiten') {
// Datei-Quelle (Dokument/Bild/Audio): Aktionen statt Link
return `<div class="src-article src-upload">
<div><span class="src-article-date">${t}</span>${h}</div>
<div class="ju-actions">${this._uploadActions(up)}
<button class="ju-btn ju-btn-del" onclick="Studio.deleteUploadItem(${up.id})">Entfernen</button>
</div>
<div class="ju-media" id="ju-media-${up.id}"></div>
</div>`;
}
return a.source_url
? `<a class="src-article" href="${UI.escape(a.source_url)}" target="_blank" rel="noopener"><span class="src-article-date">${t}</span>${h}</a>`
: `<div class="src-article"><span class="src-article-date">${t}</span>${h}</div>`;
}).join('');
return `<div class="src-item open" data-source="${nameEsc}">
<div class="src-head">
<input type="checkbox" class="src-check" checked onclick="event.stopPropagation();Studio.toggleSource('${nameEsc.replace(/'/g, "\\'")}', this.checked)">
<span class="src-name" onclick="Studio.toggleSrcOpen(this)" title="${nameEsc}">${nameEsc}</span>
<span class="src-badges">${badges}</span>
<span class="src-lang">${[...g.langs].join('/')}</span>
<span class="src-count">${g.arts.length}</span>
</div>
<div class="src-articles">${arts}</div>
</div>`;
},
toggleSrcOpen(nameEl) {
const item = nameEl.closest('.src-item');
if (item) item.classList.toggle('open');
},
toggleCat(cat) {
if (!this._collapsedCats) this._collapsedCats = new Set();
const el = document.querySelector(`.src-cat[data-cat="${cat}"]`);
if (this._collapsedCats.has(cat)) { this._collapsedCats.delete(cat); if (el) el.classList.remove('collapsed'); }
else { this._collapsedCats.add(cat); if (el) el.classList.add('collapsed'); }
},
toggleSource(name, on) {
if (this.selected === null) {
// bisher alle aktiv -> Set aus allen Namen bilden
this.selected = new Set(Object.values(this.articles.reduce((m, a) => {
m[a.source || 'Unbekannt'] = a.source || 'Unbekannt'; return m;
}, {})));
}
if (on) this.selected.add(name); else this.selected.delete(name);
const item = document.querySelector(`.src-item[data-source="${CSS.escape(name)}"]`);
if (item) item.classList.toggle('deselected', !on);
// "Alle"-Checkbox synchronisieren
const total = document.querySelectorAll('.src-item').length;
document.getElementById('src-select-all').checked = this.selected.size >= total;
},
toggleAllSources(on) {
document.querySelectorAll('.src-item').forEach(item => {
item.classList.toggle('deselected', !on);
const cb = item.querySelector('.src-check');
if (cb) cb.checked = on;
});
if (on) { this.selected = null; }
else { this.selected = new Set(); }
},
_activeSourceFilter() {
if (this.selected === null) return null; // alle
return [...this.selected];
},
// Live-Filter der Quellenliste (Name + Artikel-Ueberschriften)
filterSources(q) {
const term = (q || '').trim().toLowerCase();
document.querySelectorAll('#sources-list .src-item').forEach(item => {
const name = (item.dataset.source || '').toLowerCase();
let match = !term || name.includes(term);
if (!match) {
const arts = item.querySelectorAll('.src-article');
for (const a of arts) {
if (a.textContent.toLowerCase().includes(term)) { match = true; break; }
}
}
item.classList.toggle('filtered-out', !match);
});
},
// === Quellen-Ingest (Upload/URL -> Artikel) ===
_ingCat: {
webseiten: { label: 'Webseiten', icon: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>' },
dokumente: { label: 'Dokumente', icon: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>' },
bilder: { label: 'Bilder', icon: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>' },
sprachnachrichten:{ label: 'Sprachnachrichten', icon: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg>' },
},
toggleIngest() {
const p = document.getElementById('ingest-panel');
if (p) p.hidden = !p.hidden;
},
dzOver(e) { e.preventDefault(); document.getElementById('dropzone').classList.add('drag'); },
dzLeave(e) { e.preventDefault(); document.getElementById('dropzone').classList.remove('drag'); },
dzDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('drag');
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length) this.dzFiles(files);
},
dzFiles(fileList) {
const files = Array.from(fileList || []);
if (files.length) this._submitUpload(files, null);
},
urlKey(e) { if (e.key === 'Enter') { e.preventDefault(); this.addUrl(); } },
addUrl() {
const inp = document.getElementById('ingest-url');
const u = (inp.value || '').trim();
if (!u) return;
if (!/^https?:\/\//i.test(u)) { UI.showToast('URL muss mit http:// oder https:// beginnen', 'error'); return; }
inp.value = '';
this._submitUpload([], u);
},
async _submitUpload(files, url) {
if (!this.incident) return;
try {
await API.createUploads(this.incident.id, { files, url });
await this.loadUploads();
this._startUploadPoll();
} catch (e) {
UI.showToast((e && e.message) || 'Hinzufügen fehlgeschlagen', 'error');
}
},
async loadUploads() {
if (!this.incident) return;
let res;
try { res = await API.listUploads(this.incident.id); }
catch (e) { return; }
const prevDone = new Set((this.uploads || []).filter(u => u.status === 'done').map(u => u.id));
this.uploads = (res && res.uploads) || [];
this.renderUploads();
// Frisch fertige Jobs -> neue Artikel in die Quellenliste übernehmen
const freshlyDone = this.uploads.some(u => u.status === 'done' && !prevDone.has(u.id) && !this._seenDone.has(u.id));
this.uploads.forEach(u => { if (u.status === 'done') this._seenDone.add(u.id); });
if (freshlyDone) this._refreshCorpus();
const active = this.uploads.some(u => u.status === 'queued' || u.status === 'processing');
if (active) this._startUploadPoll(); else this._stopUploadPoll();
},
_startUploadPoll() {
if (this._uploadPoll) return;
this._uploadPoll = setInterval(() => this.loadUploads(), 1800);
},
_stopUploadPoll() {
if (this._uploadPoll) { clearInterval(this._uploadPoll); this._uploadPoll = null; }
},
async _refreshCorpus() {
try {
const arts = await API.getArticles(this.incident.id, { limit: 500 }).catch(() => ({ articles: [] }));
this.articles = Array.isArray(arts) ? arts : (arts.articles || []);
this.renderSources();
} catch (e) { /* nicht kritisch */ }
},
renderUploads() {
const host = document.getElementById('ingest-jobs');
if (!host) return;
// Nur laufende/fehlerhafte Jobs hier; fertige erscheinen in der Quellenliste.
const ups = (this.uploads || []).filter(u => u.status === 'queued' || u.status === 'processing' || u.status === 'error');
if (!ups.length) { host.innerHTML = ''; return; }
// nach Kategorie gruppieren (feste Reihenfolge)
const order = ['webseiten', 'dokumente', 'bilder', 'sprachnachrichten'];
const groups = {};
ups.forEach(u => { (groups[u.category] = groups[u.category] || []).push(u); });
let html = '';
order.filter(c => groups[c]).forEach(cat => {
const meta = this._ingCat[cat] || { label: cat, icon: '' };
html += `<div class="ju-group"><div class="ju-group-head">${meta.icon}<span>${meta.label}</span><span class="ju-count">${groups[cat].length}</span></div>`;
groups[cat].forEach(u => { html += this._renderUploadItem(u); });
html += `</div>`;
});
host.innerHTML = html;
},
_renderUploadItem(u) {
const name = UI.escape(u.filename || u.source_url || 'Quelle');
const busy = (u.status === 'queued' || u.status === 'processing');
let status = '';
if (busy) {
const pct = Math.max(3, u.progress || 0);
status = `<div class="ju-bar"><span style="width:${pct}%"></span></div>
<div class="ju-stage">${UI.escape(u.stage || 'In Arbeit')} · ${pct}%</div>`;
} else if (u.status === 'error') {
status = `<div class="ju-err">Fehler: ${UI.escape(u.error || 'unbekannt')}</div>`;
} else if (u.status === 'done') {
status = `<div class="ju-actions">${this._uploadActions(u)}</div>`;
}
return `<div class="ju-item ${u.status}" data-uid="${u.id}">
<div class="ju-row">
<span class="ju-name" title="${name}">${name}</span>
<button class="ju-del" title="Entfernen" onclick="Studio.deleteUploadItem(${u.id})">&times;</button>
</div>
${status}
<div class="ju-media" id="ju-media-${u.id}"></div>
</div>`;
},
_uploadActions(u) {
const aid = u.article_id || 0;
const btns = [];
if (u.category === 'sprachnachrichten') {
btns.push(`<button class="ju-btn" onclick="Studio.toggleText(${u.id},${aid})">Transkription</button>`);
btns.push(`<button class="ju-btn" onclick="Studio.playAudio(${u.id})">Anhören</button>`);
} else if (u.category === 'bilder') {
btns.push(`<button class="ju-btn" onclick="Studio.toggleText(${u.id},${aid})">Text (OCR)</button>`);
btns.push(`<button class="ju-btn" onclick="Studio.openOriginal(${u.id})">Bild öffnen</button>`);
} else if (u.category === 'webseiten') {
btns.push(`<button class="ju-btn" onclick="Studio.toggleText(${u.id},${aid})">Text ansehen</button>`);
if (u.source_url) btns.push(`<a class="ju-btn" href="${UI.escape(u.source_url)}" target="_blank" rel="noopener">Link öffnen</a>`);
} else {
btns.push(`<button class="ju-btn" onclick="Studio.toggleText(${u.id},${aid})">Text ansehen</button>`);
if (u.has_file) btns.push(`<button class="ju-btn" onclick="Studio.openOriginal(${u.id})">Original</button>`);
}
return btns.join('');
},
toggleText(uid, articleId) {
const host = document.getElementById('ju-media-' + uid);
if (!host) return;
if (host.dataset.open === 'text') { host.innerHTML = ''; host.dataset.open = ''; return; }
const art = (this.articles || []).find(a => a.id === articleId);
const txt = art ? (art.content_de || art.content_original || '') : '';
host.innerHTML = `<div class="ju-text">${txt ? UI.escape(txt).slice(0, 8000).replace(/\n/g, '<br>') : '<span class="empty-hint">Text wird vorbereitet …</span>'}</div>`;
host.dataset.open = 'text';
if (!art) this._refreshCorpus();
},
async playAudio(uid) {
const host = document.getElementById('ju-media-' + uid);
if (!host) return;
if (host.dataset.open === 'audio') { host.innerHTML = ''; host.dataset.open = ''; return; }
host.innerHTML = '<span class="empty-hint">Lädt …</span>';
host.dataset.open = 'audio';
try {
const url = await API.fetchUploadBlobUrl(this.incident.id, uid);
host.innerHTML = `<audio controls autoplay src="${url}" style="width:100%;margin-top:6px;"></audio>`;
} catch (e) {
host.innerHTML = `<span class="ju-err">${UI.escape(e.message || 'Audio-Fehler')}</span>`;
}
},
async openOriginal(uid) {
try {
const url = await API.fetchUploadBlobUrl(this.incident.id, uid);
window.open(url, '_blank', 'noopener');
} catch (e) { UI.showToast(e.message || 'Datei-Fehler', 'error'); }
},
async deleteUploadItem(uid) {
const ok = await this.askConfirm({
title: 'Quelle entfernen?',
okLabel: 'Entfernen',
body: '<p class="confirm-warn">Die hochgeladene Datei und der daraus erzeugte Text werden entfernt.</p>',
});
if (!ok) return;
try {
await API.deleteUpload(this.incident.id, uid);
this._seenDone.delete(uid);
await this.loadUploads();
this._refreshCorpus();
} catch (e) { UI.showToast(e.message || 'Löschen fehlgeschlagen', 'error'); }
},
// === Spalte 3: Artefakte ===
renderArtifacts(inc, fcs, locs, catLabels) {
const sources = this._sources(inc);
// Lagebild / Recherchebericht + Neueste Entwicklungen / Zusammenfassung
// Bei Recherche die Zusammenfassung/Ueberblick aus dem Bericht herauslösen,
// damit sie nicht doppelt (auch im eigenen Zusammenfassung-Tab) erscheint.
const summaryEl = document.getElementById('art-summary-body');
const latestEl = document.getElementById('art-latest-body');
if (inc.type === 'research') {
const ex = UI.extractZusammenfassung(inc.summary || '');
const reportText = ex.zusammenfassung ? ex.remaining : (inc.summary || '');
summaryEl.innerHTML = UI.renderSummary(reportText, sources, inc.type);
latestEl.innerHTML = ex.zusammenfassung
? UI.renderZusammenfassung(ex.zusammenfassung, sources)
: '<span class="empty-hint">Keine separate Zusammenfassung.</span>';
} else {
summaryEl.innerHTML = UI.renderSummary(inc.summary || '', sources, inc.type);
latestEl.innerHTML = UI.renderLatestDevelopments(inc.latest_developments || '', sources);
}
// Faktencheck
const fcBody = document.getElementById('art-fc-body');
App._fcHidden = new Set();
if (fcs && fcs.length) {
document.getElementById('art-fc-meta').textContent = fcs.length + ' geprüft';
const filters = UI.renderFactCheckFilters(fcs);
fcBody.innerHTML = (filters ? `<div style="margin-bottom:8px;">${filters}</div>` : '')
+ fcs.map(fc => UI.renderFactCheck(fc)).join('');
} else {
document.getElementById('art-fc-meta').textContent = '';
fcBody.innerHTML = '<span class="empty-hint">Noch keine Faktenchecks.</span>';
}
// Faktencheck-Historie (frühere, komplett-neu ersetzte Läufe)
fcBody.insertAdjacentHTML('beforeend',
`<details class="fc-history" ontoggle="Studio.onFcHistoryToggle(this)">
<summary>Frühere Faktencheck-Läufe</summary>
<div class="fc-history-body"><span class="empty-hint">Beim Öffnen geladen.</span></div>
</details>`);
// Karte (Kategorie-Legende kommt aus /locations mit)
UI.renderMap(locs || [], catLabels || null);
// Ereignis-Timeline: lazy beim Öffnen des Tabs laden
this._tlLoaded = false;
document.getElementById('art-tl-meta').textContent = '';
document.getElementById('art-tl-body').innerHTML = '<span class="empty-hint">Wird beim Öffnen geladen.</span>';
// Snapshots-Meta zurücksetzen
document.getElementById('art-snap-meta').textContent = '';
document.getElementById('art-snap-body').innerHTML = '<span class="empty-hint">Wird beim Öffnen geladen.</span>';
},
// Aktivitaets-/Ereignis-Timeline (lazy beim Öffnen des Tabs)
async loadTimeline(force) {
if (this._tlLoaded && !force) return;
this._tlLoaded = true;
const el = document.getElementById('art-tl-body');
if (!this.incident) return;
el.innerHTML = '<span class="empty-hint">Lade Ereignisse …</span>';
let events = [];
try {
const res = await API.getEvents(this.incident.id);
events = (res && res.events) || [];
} catch (e) {
el.innerHTML = '<span class="empty-hint">Ereignisse konnten nicht geladen werden.</span>';
return;
}
document.getElementById('art-tl-meta').textContent = events.length + ' Ereignisse';
if (!events.length) { el.innerHTML = '<span class="empty-hint">Noch keine Ereignisse.</span>'; return; }
// nach Typ in Untersektionen gruppieren (Reihenfolge in der Gruppe: neueste zuerst)
const groups = {};
events.forEach(ev => { (groups[ev.type] = groups[ev.type] || []).push(ev); });
const sections = this._evSections.slice();
Object.keys(groups).forEach(k => {
if (!sections.some(s => s.key === k)) {
sections.push({ key: k, label: (this._evMeta[k] || {}).label || 'Sonstige' });
}
});
el.innerHTML = sections
.filter(s => groups[s.key] && groups[s.key].length)
.map(s => {
const icon = (this._evMeta[s.key] || {}).icon || '';
const items = groups[s.key].map(ev => this._renderEvent(ev)).join('');
return `<details class="ev-section sec-${s.key}" open>
<summary class="ev-section-head">
<span class="ev-section-icon">${icon}</span>
<span class="ev-section-title">${UI.escape(s.label)}</span>
<span class="ev-section-count">${groups[s.key].length}</span>
</summary>
<div class="ev-section-body">${items}</div>
</details>`;
}).join('');
},
_fmtTs(ts) {
const d = parseUTC(ts);
return d ? this._fmt(d, true) : '';
},
_renderEvent(ev) {
const time = this._fmtTs(ev.ts);
let body;
if (ev.type === 'chat_qa') {
const d = ev.detail || '';
const marker = '— Antwort —';
const mi = d.indexOf(marker);
const q = (mi >= 0 ? d.slice(0, mi) : (ev.title || '')).trim();
const a = mi >= 0 ? d.slice(mi + marker.length).trim() : '';
body = `<div class="ev-title">${UI.escape(q)}</div>` +
(a ? `<details class="ev-answer"><summary>Antwort anzeigen</summary><div class="ev-answer-body">${this._renderReply(a, [])}</div></details>` : '');
} else if (ev.type === 'article_ingest') {
const t = ev.url
? `<a href="${UI.escape(ev.url)}" target="_blank" rel="noopener">${UI.escape(ev.title || '')}</a>`
: UI.escape(ev.title || '');
body = `<div class="ev-title">${t}</div>` + (ev.source ? `<div class="ev-src">${UI.escape(ev.source)}</div>` : '');
} else {
body = `<div class="ev-title">${UI.escape(ev.title || '')}</div>` +
(ev.source ? `<div class="ev-src">${UI.escape(ev.source)}</div>` : '');
}
return `<div class="ev-item"><span class="ev-time">${time}</span><div class="ev-main">${body}</div></div>`;
},
async loadSnapshots() {
if (this._snapLoaded) return;
this._snapLoaded = true;
const body = document.getElementById('art-snap-body');
try {
const snaps = await API.getSnapshots(this.incident.id) || [];
const list = Array.isArray(snaps) ? snaps : (snaps.snapshots || []);
document.getElementById('art-snap-meta').textContent = list.length + ' Berichte';
if (!list.length) { body.innerHTML = '<span class="empty-hint">Noch keine früheren Lageberichte.</span>'; return; }
body.innerHTML = list.map(s => {
const t = s.created_at ? this._fmt(parseUTC(s.created_at)) : '';
const prev = UI.escape((s.summary_preview || '').trim());
const meta = `${s.article_count || 0} Artikel · ${s.fact_check_count || 0} Faktenchecks`;
return `<details class="hist-item" ontoggle="Studio.onSnapToggle(this, ${s.id})">
<summary><span class="hist-time">${t}</span><span class="hist-meta">${meta}</span></summary>
<div class="hist-body"><div class="hist-prev">${prev}${prev ? ' …' : ''}</div></div>
</details>`;
}).join('');
} catch (e) {
body.innerHTML = '<span class="empty-hint">Konnte Lageberichte nicht laden.</span>';
}
},
async onSnapToggle(el, snapId) {
if (!el.open || el.dataset.loaded) return;
el.dataset.loaded = '1';
const bodyEl = el.querySelector('.hist-body');
bodyEl.innerHTML = '<span class="empty-hint">Lädt …</span>';
try {
const snap = await API.getSnapshot(this.incident.id, snapId);
let sources = [];
try { sources = JSON.parse(snap.sources_json || '[]'); } catch (_) {}
bodyEl.innerHTML = UI.renderSummary(snap.summary || '', sources, this.incident && this.incident.type);
} catch (e) {
bodyEl.innerHTML = '<span class="empty-hint">Konnte Bericht nicht laden.</span>';
}
},
async onFcHistoryToggle(el) {
if (!el.open || el.dataset.loaded) return;
el.dataset.loaded = '1';
const body = el.querySelector('.fc-history-body');
try {
const res = await API.listFactcheckRuns(this.incident.id);
const runs = (res && res.runs) || [];
if (!runs.length) { body.innerHTML = '<span class="empty-hint">Noch keine früheren Läufe.</span>'; return; }
body.innerHTML = runs.map(r => {
const t = r.created_at ? this._fmt(parseUTC(r.created_at)) : '';
return `<details class="hist-item" ontoggle="Studio.onFcRunToggle(this, ${r.id})">
<summary><span class="hist-time">${t}</span><span class="hist-meta">${r.fact_count || 0} Fakten</span></summary>
<div class="hist-body"><span class="empty-hint">Lädt …</span></div>
</details>`;
}).join('');
} catch (e) {
body.innerHTML = '<span class="empty-hint">Konnte Historie nicht laden.</span>';
}
},
async onFcRunToggle(el, runId) {
if (!el.open || el.dataset.loaded) return;
el.dataset.loaded = '1';
const body = el.querySelector('.hist-body');
try {
const res = await API.getFactcheckRun(this.incident.id, runId);
const facts = (res && res.facts) || [];
body.innerHTML = facts.length
? facts.map(fc => UI.renderFactCheck(fc)).join('')
: '<span class="empty-hint">Keine Fakten in diesem Lauf.</span>';
} catch (e) {
body.innerHTML = '<span class="empty-hint">Konnte Lauf nicht laden.</span>';
}
},
// === Verschiebbare Trennung Tab-Panel <-> Konversation ===
_initDivider() {
const divider = document.getElementById('center-divider');
const center = document.getElementById('studio-center');
if (!divider || !center) return;
const chat = center.querySelector('.chat-region');
// gespeicherte Höhe wiederherstellen
const saved = parseInt(localStorage.getItem('studio_chat_h') || '', 10);
if (saved) center.style.setProperty('--chat-h', saved + 'px');
let startY = 0, startH = 0;
const onMove = (e) => {
const dy = e.clientY - startY;
let h = startH - dy; // nach oben ziehen => Chat größer
const minChat = 140;
const maxChat = center.clientHeight - 180; // Tab-Panel behält Mindesthoehe
h = Math.max(minChat, Math.min(Math.max(minChat, maxChat), h));
center.style.setProperty('--chat-h', h + 'px');
};
const onUp = (e) => {
divider.classList.remove('dragging');
document.body.style.userSelect = '';
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
try { divider.releasePointerCapture(e.pointerId); } catch (_) {}
// Karte neu vermessen, falls Karten-Tab aktiv
if (this.activeTab === 'map' && UI.invalidateMap) UI.invalidateMap();
const cur = center.style.getPropertyValue('--chat-h');
if (cur.endsWith('px')) localStorage.setItem('studio_chat_h', parseInt(cur, 10));
};
divider.addEventListener('pointerdown', (e) => {
startY = e.clientY;
startH = chat.getBoundingClientRect().height;
divider.classList.add('dragging');
document.body.style.userSelect = 'none';
try { divider.setPointerCapture(e.pointerId); } catch (_) {}
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
e.preventDefault();
});
},
// Tabs per Drag&Drop umsortieren (Delegation auf der statischen Tabbar)
_initTabDnD() {
const bar = document.getElementById('center-tabbar');
if (!bar) return;
const clearMarkers = () => bar.querySelectorAll('.center-tab').forEach(t =>
t.classList.remove('drop-before', 'drop-after'));
bar.addEventListener('dragstart', (e) => {
const tab = e.target.closest('.center-tab');
if (!tab) return;
this._dragKey = tab.dataset.art;
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', tab.dataset.art); } catch (_) {}
tab.classList.add('dragging-tab');
});
bar.addEventListener('dragend', (e) => {
const tab = e.target.closest('.center-tab');
if (tab) tab.classList.remove('dragging-tab');
this._dragKey = null;
clearMarkers();
});
bar.addEventListener('dragover', (e) => {
if (!this._dragKey) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
clearMarkers();
const over = e.target.closest('.center-tab');
if (over && over.dataset.art !== this._dragKey) {
const r = over.getBoundingClientRect();
const after = (e.clientX - r.left) > r.width / 2;
over.classList.add(after ? 'drop-after' : 'drop-before');
}
});
bar.addEventListener('drop', (e) => {
if (!this._dragKey) return;
e.preventDefault();
const fromKey = this._dragKey;
const over = e.target.closest('.center-tab');
let toIndex;
if (over && over.dataset.art !== fromKey) {
const r = over.getBoundingClientRect();
const after = (e.clientX - r.left) > r.width / 2;
const overIdx = this.openTabs.indexOf(over.dataset.art);
toIndex = after ? overIdx + 1 : overIdx;
} else {
toIndex = this.openTabs.length;
}
const fromIdx = this.openTabs.indexOf(fromKey);
if (fromIdx === -1) return;
this.openTabs.splice(fromIdx, 1);
if (fromIdx < toIndex) toIndex--;
this.openTabs.splice(toIndex, 0, fromKey);
this._dragKey = null;
clearMarkers();
this._renderTabbar(); // baut Tabs neu, aktiver Zustand bleibt erhalten
});
},
// === Studio-Tabs in der Mitte ===
_tabTitle(key) {
const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`);
return el ? el.textContent.trim() : key;
},
_tabIcon(key) {
const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-icon`);
return el ? el.innerHTML : '';
},
_initTabs() {
this.openTabs = [];
this.activeTab = null;
document.getElementById('studio-center').classList.remove('has-tabs');
document.getElementById('center-tabbar').innerHTML = '';
document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => m.classList.remove('open', 'active'));
// Standard: Lagebild/Recherchebericht als erster Tab
this.openTab('summary');
},
_renderTabbar() {
const bar = document.getElementById('center-tabbar');
bar.innerHTML = this.openTabs.map(key => {
const k = key.replace(/'/g, "\\'");
return `<div class="center-tab${key === this.activeTab ? ' active' : ''}" data-art="${key}" draggable="true" onclick="Studio.activateTab('${k}')" role="tab">
<span class="ct-icon">${this._tabIcon(key)}</span>
<span class="ct-title">${UI.escape(this._tabTitle(key))}</span>
<span class="center-tab-close" title="Schließen" onclick="Studio.closeTab('${k}', event)">${this._closeSvg}</span>
</div>`;
}).join('');
document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => {
m.classList.toggle('open', this.openTabs.includes(m.dataset.art));
});
},
openTab(key) {
if (!this.openTabs.includes(key)) this.openTabs.push(key);
document.getElementById('studio-center').classList.add('has-tabs');
this._renderTabbar();
this.activateTab(key);
},
activateTab(key) {
this.activeTab = key;
document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => {
p.classList.toggle('active', p.dataset.art === key);
});
document.querySelectorAll('#center-tabbar .center-tab').forEach(t => {
t.classList.toggle('active', t.dataset.art === key);
});
document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => {
m.classList.toggle('active', m.dataset.art === key);
});
// Nebenwirkungen: Karte neu vermessen / Snapshots lazy laden
if (key === 'map') {
setTimeout(() => {
if (UI.retryPendingMap) UI.retryPendingMap();
if (UI.invalidateMap) UI.invalidateMap();
}, 60);
}
if (key === 'snapshots') this.loadSnapshots();
if (key === 'timeline') this.loadTimeline();
},
closeTab(key, ev) {
if (ev) ev.stopPropagation();
const idx = this.openTabs.indexOf(key);
if (idx === -1) return;
this.openTabs.splice(idx, 1);
const mi = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"]`);
if (mi) mi.classList.remove('open', 'active');
if (!this.openTabs.length) {
this.activeTab = null;
document.getElementById('studio-center').classList.remove('has-tabs');
this._renderTabbar();
document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => p.classList.remove('active'));
return;
}
// Nachbar-Tab aktivieren, wenn der aktive geschlossen wurde
let nextKey = this.activeTab;
if (this.activeTab === key || !this.openTabs.includes(this.activeTab)) {
nextKey = this.openTabs[Math.min(idx, this.openTabs.length - 1)];
}
this._renderTabbar();
this.activateTab(nextKey);
},
// === Spalte 2: RAG-Chat ===
renderChatIntro(inc) {
const box = document.getElementById('chat-messages');
const sources = this._sources(inc);
let intro = '';
if (inc.summary) {
const ex = inc.type === 'research' ? UI.extractZusammenfassung(inc.summary) : { zusammenfassung: null };
const short = ex.zusammenfassung
? UI.renderZusammenfassung(ex.zusammenfassung, sources)
: UI.renderSummary(inc.summary, sources, inc.type);
intro = `<div class="chat-msg chat-msg-assistant pinned">
<div class="chat-pin-label">${inc.type === 'research' ? 'Recherchebericht' : 'Lagebild'}</div>
${short}</div>`;
} else {
intro = `<div class="chat-msg chat-msg-assistant">Zu diesem Fall gibt es noch kein Lagebild. Starte eine Aktualisierung.</div>`;
}
box.innerHTML = intro;
box.scrollTop = 0;
// Bewusst keine Vorschlagsfragen mehr (auch nicht bei "Neu"): der Chat
// startet leer; gestellte Fragen landen in der Ereignis-Timeline.
document.getElementById('chat-suggestions').innerHTML = '';
},
askChip(btn) {
document.getElementById('chat-input').value = btn.textContent;
this.sendChat();
},
toggleScope(on) {
this.searchAllScope = !!on;
const lbl = document.getElementById('chat-scope');
if (lbl) lbl.classList.toggle('on', this.searchAllScope);
},
autoGrow(ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; },
chatKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendChat(); }
},
resetChat() {
this.convId = null;
if (this.incident) this.renderChatIntro(this.incident);
},
async sendChat() {
const ta = document.getElementById('chat-input');
const msg = (ta.value || '').trim();
if (!msg || !this.incident) return;
const box = document.getElementById('chat-messages');
box.insertAdjacentHTML('beforeend',
`<div class="chat-msg chat-msg-user">${UI.escape(msg)}</div>`);
ta.value = ''; this.autoGrow(ta);
const thinking = document.createElement('div');
thinking.className = 'chat-msg chat-msg-assistant chat-thinking';
thinking.textContent = 'Denkt nach ...';
box.appendChild(thinking);
box.scrollTop = box.scrollHeight;
document.getElementById('chat-send').disabled = true;
try {
const res = await API.askIncident(this.incident.id, msg, {
conversation_id: this.convId,
source_filter: this._activeSourceFilter(),
scope: this.searchAllScope ? 'alle' : 'fall',
});
this.convId = res.conversation_id || this.convId;
thinking.classList.remove('chat-thinking');
thinking.innerHTML = this._renderReply(res.reply || '', res.sources || []);
// Analyst bietet gezielte Recherche an, wenn die Materialien nicht reichen
if (res.offer && res.offer.needs_research) {
thinking.insertAdjacentHTML('beforeend', this._renderOffer(res.offer));
}
// Frage & Antwort wurden serverseitig protokolliert -> Timeline auffrischen
this._tlLoaded = false;
if (this.activeTab === 'timeline') this.loadTimeline(true);
} catch (e) {
thinking.classList.remove('chat-thinking');
const notReady = e && (e.status === 404 || e.status === 405);
thinking.innerHTML = notReady
? '<span class="empty-hint">Der Lage-Chat ist noch nicht aktiviert (Backend-Neustart erforderlich).</span>'
: '<span class="empty-hint">' + UI.escape((e && e.message) || 'Fehler bei der Anfrage.') + '</span>';
} finally {
document.getElementById('chat-send').disabled = false;
box.scrollTop = box.scrollHeight;
}
},
_renderReply(text, sources) {
// Zitate [n] klickbar machen -> highlightet Quelle in Spalte 1
let html = UI.escape(text);
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\n/g, '<br>');
html = html.replace(/\[(\d+)\]/g, (m, n) => {
const src = (sources || []).find(s => String(s.nr) === n);
const title = src ? UI.escape(src.source || src.name || '') : '';
return `<span class="chat-cite" title="${title}" onclick="Studio.gotoCite('${src ? UI.escape(src.source || src.name || '') : ''}')">[${n}]</span>`;
});
return html;
},
gotoCite(sourceName) {
if (!sourceName) return;
const item = document.querySelector(`.src-item[data-source="${CSS.escape(sourceName)}"]`);
if (!item) { UI.showToast('Quelle nicht in der Liste', 'info'); return; }
item.classList.add('open');
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
item.classList.remove('cite-flash'); void item.offsetWidth;
item.classList.add('cite-flash');
},
// Recherche-Angebot des Analysten (wenn die Materialien die Frage nicht hergeben)
_renderOffer(offer) {
const focus = UI.escape(offer.focus || '');
const add = UI.escape(offer.description_addition || '');
const payload = encodeURIComponent(JSON.stringify({
focus: offer.focus || '', description_addition: offer.description_addition || '',
}));
return `<div class="chat-offer" data-offer="${payload}">
<div class="chat-offer-title">Dazu liegt in diesem Fall noch nichts vor.</div>
<div class="chat-offer-text">Ich kann die Fallbeschreibung um diesen Aspekt ergänzen und gezielt dazu recherchieren, ausschließlich zu dieser Frage.</div>
<label class="chat-offer-label">Ergänzung der Fallbeschreibung (editierbar):</label>
<textarea class="chat-offer-add" rows="2">${add}</textarea>
<div class="chat-offer-focus">Suchfokus: <em>${focus}</em></div>
<button class="studio-btn chat-offer-btn" type="button" onclick="Studio.runClarify(this)">Ergänzen und gezielt recherchieren</button>
</div>`;
},
async runClarify(btn) {
const card = btn.closest('.chat-offer');
if (!card || !this.incident) return;
let data = {};
try { data = JSON.parse(decodeURIComponent(card.dataset.offer || '%7B%7D')); } catch (e) {}
const addEl = card.querySelector('.chat-offer-add');
const description_addition = addEl ? (addEl.value || '').trim() : (data.description_addition || '');
const focus = data.focus || '';
if (!focus) return;
btn.disabled = true; btn.textContent = 'Recherche läuft …';
try {
await API.clarify(this.incident.id, { focus, description_addition });
this._pollClarify(card);
} catch (e) {
btn.disabled = false; btn.textContent = 'Ergänzen und gezielt recherchieren';
UI.showToast((e && e.message) || 'Recherche konnte nicht gestartet werden', 'error');
}
},
_pollClarify(card) {
const incId = this.incident && this.incident.id;
if (!incId) return;
const btn = card.querySelector('.chat-offer-btn');
const textEl = card.querySelector('.chat-offer-text');
const tick = async () => {
let st = null;
try { const r = await API.getRunStatus(incId); st = r && r.state; } catch (e) {}
if (st && st.status === 'running') { setTimeout(tick, 4000); return; }
if (st && st.status === 'done') {
const n = (st.result && st.result.inserted) || 0;
card.innerHTML = `<div class="chat-offer-title">Gezielte Recherche abgeschlossen.</div>
<div class="chat-offer-text">${n} neue Meldung${n === 1 ? '' : 'en'} zur Frage erfasst. Starte Analyse oder Faktencheck neu und stelle die Frage erneut.</div>`;
this._tlLoaded = false;
try { this.fresh = await API.getFreshness(incId); this._renderFreshness(); } catch (e) {}
} else if (st && st.status === 'error') {
if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; }
if (textEl) textEl.textContent = 'Die Recherche ist fehlgeschlagen. Bitte erneut versuchen.';
} else {
setTimeout(tick, 4000);
}
};
setTimeout(tick, 3000);
},
// === Bausteine (Kacheln in Spalte 3) ===
// Ein Klick startet den Baustein sofort. Läuft er, zeigt die Kachel Spinner +
// Fortschritt; bei den Orchestrator-Bausteinen (Sammeln, Kompletter Lauf) bricht
// ein zweiter Klick ab. Analyse/Faktencheck/Geoparsing haben backendseitig keinen
// Abbruch - ihre Kachel ist während des Laufs gesperrt statt einen toten Knopf zu zeigen.
_CANCELLABLE: ['collect', 'full'],
_STAGE_LABELS: {
collect: 'Sammeln', analyze: 'Analyse', factcheck: 'Faktencheck',
geoparse: 'Geoparsing', network: 'Netzwerkanalyse', full: 'Kompletter Lauf',
},
// "Aktualisieren" von außen (Tastatur/WS-Resync) = kompletter Lauf
async refresh() { return this.runStage('full'); },
// Alle Start-Elemente tragen data-stage: die ↻-Knoepfe in den Karten, "Kompletter
// Lauf" und der Sammeln-Knopf in der Quellenspalte.
_stageEls() { return Array.from(document.querySelectorAll('[data-stage]')); },
_stageEl(stage) { return document.querySelector(`[data-stage="${CSS.escape(stage)}"]`); },
_stagesIdle() {
this._runningStage = null;
// Ohne Artikel können Analyse, Faktencheck und Geoparsing nicht laufen
// (das Backend wirft "Keine Artikel vorhanden") - erst gar nicht anbieten.
const ohneArtikel = !(this.articles || []).length;
const brauchtArtikel = ['analyze', 'factcheck', 'geoparse'];
this._stageEls().forEach(b => {
b.classList.remove('running');
const s = b.dataset.stage;
// Netzwerkanalyse bleibt gesperrt (Baustein folgt im Backend)
b.disabled = (s === 'network') || !this.incident
|| (ohneArtikel && brauchtArtikel.includes(s));
});
document.querySelectorAll('.art-card').forEach(c => c.classList.remove('busy'));
const ra = document.querySelector('.run-all .ra-state');
if (ra) ra.textContent = '';
this._renderFreshness();
},
_stageRunning(stage, text) {
if (!stage) return;
this._runningStage = stage;
// Sobald etwas läuft, ist die Startfrage beantwortet - auch wenn der Lauf
// über den Sammeln-Knopf links statt über das Panel ausgelöst wurde.
const panel = document.getElementById('start-panel');
if (panel) panel.hidden = true;
if (this.incident) localStorage.setItem('studio_start_' + this.incident.id, 'run');
const cancellable = this._CANCELLABLE.includes(stage);
this._stageEls().forEach(b => {
const isThis = b.dataset.stage === stage;
b.classList.toggle('running', isThis);
// Die laufende Aktion bleibt nur klickbar, wenn sie abbrechbar ist
b.disabled = isThis ? !cancellable : true;
});
document.querySelectorAll('.art-card').forEach(c => {
c.classList.toggle('busy', c.dataset.card === stage);
});
const ra = document.querySelector('.run-all .ra-state');
if (ra) ra.textContent = stage === 'full' ? (text || 'läuft …') + ' · Abbrechen' : '';
this._renderFreshness(text);
},
// App-Zeitstempel stehen bereits in lokaler Zeit -> nur umformatieren, NICHT als UTC lesen
_fmtStamp(ts) {
const m = String(ts || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
return m ? `${m[3]}.${m[2]}. ${m[4]}:${m[5]}` : '';
},
_setNote(card, cls, html) {
const el = document.querySelector(`.art-note[data-note="${card}"]`);
if (!el) return;
el.className = 'art-note show ' + cls;
el.innerHTML = html;
},
/**
* Datenstand je Karte anzeigen. Die Karte meldet selbst, ob ihr Ergebnis noch
* zur Datenlage passt - so muss niemand die Pipeline im Kopf haben.
*/
_renderFreshness(runText) {
const f = this.fresh;
const run = this._runningStage;
const spin = '<span class="an-spin"></span>';
const btn = (stage, label) =>
`<button type="button" onclick="Studio.runStage('${stage}')">${label}</button>`;
// Sammeln-Knopf spiegelt den Lauf mit
const cb = document.getElementById('collect-btn');
if (cb) cb.classList.toggle('running', run === 'collect');
const running = (card, label) =>
this._setNote(card, 'running', `${spin} ${label} läuft …`);
const waiting = (card) =>
this._setNote(card, 'blocked',
`wartet, bis ${this._STAGE_LABELS[run] || 'der Baustein'} fertig ist`);
// Ohne Freshness-Daten (alte Lage frisch geladen / Endpunkt weg): Karten neutral lassen
const cards = ['analyze', 'factcheck', 'geoparse'];
if (run) {
cards.forEach(c => (run === c ? running(c, this._STAGE_LABELS[c]) : waiting(c)));
if (run === 'collect' || run === 'full') {
cards.forEach(c => waiting(c));
}
}
this._setNote('network', 'blocked', 'Baustein folgt');
this._setNote('timeline', 'fresh', 'entsteht automatisch aus den Artikeln');
if (run || !f) return;
const arts = f.articles || 0;
// --- Lagebild (Analyse) ---
const s = f.summary || {};
const sumMeta = document.getElementById('art-summary-meta');
if (sumMeta) {
sumMeta.textContent = s.exists
? `${arts} Artikel ausgewertet · ${this._fmtStamp(s.last)}`
: 'noch nicht erzeugt';
}
if (!arts) {
this._setNote('analyze', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht');
} else if (!s.exists) {
this._setNote('analyze', 'blocked', 'noch nicht erzeugt' + btn('analyze', 'Erzeugen'));
} else if (s.pending > 0) {
this._setNote('analyze', 'stale',
`${s.pending} neue Artikel seit dem letzten Bericht` + btn('analyze', 'Aktualisieren'));
} else {
this._setNote('analyze', 'fresh', 'aktuell');
}
// --- Faktencheck ---
const fc = f.factcheck || {};
const fcMeta = document.getElementById('art-fc-meta');
if (fcMeta) {
fcMeta.textContent = fc.exists
? `${fc.facts} Fakten · ${this._fmtStamp(fc.last)}`
: 'noch nicht erzeugt';
}
if (!arts) {
this._setNote('factcheck', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht');
} else if (!fc.exists) {
this._setNote('factcheck', 'blocked', 'noch nicht erzeugt' + btn('factcheck', 'Erzeugen'));
} else if (fc.pending > 0) {
this._setNote('factcheck', 'stale',
`${fc.pending} neue Artikel seit dem letzten Faktencheck` + btn('factcheck', 'Aktualisieren'));
} else {
this._setNote('factcheck', 'fresh', 'aktuell');
}
// --- Karte (Geoparsing) --- offen ist hier nicht "neu seit", sondern "noch nicht geprüft"
const geo = f.geoparse || {};
if (!arts) {
this._setNote('geoparse', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht');
} else if (geo.pending > 0) {
this._setNote('geoparse', 'stale',
`${geo.pending} Artikel noch nicht verortet` + btn('geoparse', 'Verorten'));
} else {
this._setNote('geoparse', 'fresh', 'alle Artikel verortet');
}
// --- Nebenangaben ---
const snapMeta = document.getElementById('art-snap-meta');
if (snapMeta && !snapMeta.textContent) snapMeta.textContent = f.snapshots ? String(f.snapshots) : '';
const tlMeta = document.getElementById('art-tl-meta');
if (tlMeta && !tlMeta.textContent && f.events) tlMeta.textContent = `${f.events} Ereignisse`;
},
async runStage(stage) {
if (!this.incident) return;
// Läuft genau dieser Baustein schon? -> Klick bedeutet Abbrechen
if (this._runningStage === stage && this._CANCELLABLE.includes(stage)) {
try {
await API.cancelRefresh(this.incident.id);
UI.showToast('Abbruch angefordert …', 'info');
} catch (e) {
UI.showToast((e && e.message) || 'Abbruch fehlgeschlagen', 'error');
}
return;
}
if (this._runningStage) {
UI.showToast('Es läuft bereits ein Baustein für diese Lage.', 'info');
return;
}
const label = this._STAGE_LABELS[stage] || 'Baustein';
this._stageRunning(stage, 'startet …');
try {
if (stage === 'full') {
// Beim leeren Fall ist es eine Recherche, sonst eine Aktualisierung -
// dieselbe Aktion, aber der Nutzer soll den Namen wiedererkennen, den
// er angeklickt hat.
const erstlauf = !(this.articles || []).length;
await API.refreshIncident(this.incident.id);
this._showStatus('In Warteschlange ...');
UI.showToast(erstlauf ? 'Recherche gestartet' : 'Kompletter Lauf gestartet', 'info');
} else if (stage === 'geoparse') {
await API.triggerGeoparse(this.incident.id);
this._showStatus('Geoparsing läuft …');
this._pollGeoparse();
} else {
await API.runStage(this.incident.id, stage);
this._showStatus(label + ' läuft …');
// 'collect' läuft über die Refresh-Pipeline -> Abschluss/Reload via WebSocket
// (_wireWs: refresh_complete/refresh_error). Analyse/Faktencheck via Polling.
if (stage !== 'collect') this._pollStage();
}
this._stageRunning(stage, 'läuft …');
} catch (e) {
this._stagesIdle();
this._hideStatus();
const busy = e && e.status === 409;
UI.showToast(busy ? 'Es läuft bereits ein Baustein für diese Lage.'
: ((e && e.message) || 'Start fehlgeschlagen'), 'error');
}
},
_pollStage() {
if (this._stagePoll) return;
this._stagePoll = setInterval(async () => {
let res;
try { res = await API.getRunStatus(this.incident.id); } catch (e) { return; }
const st = res && res.state;
if (!st) { this._stopStagePoll(); this._hideStatus(); this._stagesIdle(); return; }
if (st.status === 'running') {
this._showStatus((st.label || 'Baustein') + ' läuft …');
this._stageRunning(st.stage || this._runningStage, 'läuft …');
} else {
this._stopStagePoll(); this._hideStatus(); this._stagesIdle();
if (st.status === 'done') {
UI.showToast((st.label || 'Baustein') + ' fertig', 'info');
this.softRefresh();
} else if (st.status === 'error') {
UI.showToast((st.label || 'Baustein') + ' fehlgeschlagen: ' + (st.error || ''), 'error');
}
}
}, 2000);
},
_stopStagePoll() { if (this._stagePoll) { clearInterval(this._stagePoll); this._stagePoll = null; } },
_pollGeoparse() {
if (this._geoPoll) return;
this._geoPoll = setInterval(async () => {
let st;
try { st = await API.getGeoparseStatus(this.incident.id); } catch (e) { return; }
if (st && st.status === 'done') {
this._stopGeoPoll(); this._hideStatus(); this._stagesIdle();
UI.showToast('Geoparsing fertig · ' + (st.locations || 0) + ' Orte', 'info');
this.softRefresh();
} else if (st && st.status === 'error') {
this._stopGeoPoll(); this._hideStatus(); this._stagesIdle();
UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error');
} else {
// running / idle
if (st && st.total) {
const prog = (st.processed || 0) + '/' + st.total;
this._showStatus('Geoparsing läuft … ' + prog);
this._stageRunning('geoparse', prog);
}
}
}, 2000);
},
_stopGeoPoll() { if (this._geoPoll) { clearInterval(this._geoPoll); this._geoPoll = null; } },
async _resumeStage() {
// Beim Öffnen einer Lage: läuft dort gerade ein Baustein? -> Kachel + Polling aufnehmen
this._stopStagePoll();
this._stopGeoPoll();
try {
const res = await API.getRunStatus(this.incident.id);
if (res && res.state && res.state.status === 'running') {
this._showStatus((res.state.label || 'Baustein') + ' läuft …');
this._stageRunning(res.state.stage, 'läuft …');
this._pollStage();
return;
}
} catch (e) { /* ignore */ }
try {
const g = await API.getGeoparseStatus(this.incident.id);
if (g && g.status === 'running') {
this._showStatus('Geoparsing läuft …');
this._stageRunning('geoparse', 'läuft …');
this._pollGeoparse();
}
} catch (e) { /* ignore */ }
},
// Soft-Refresh (F5): Daten neu laden statt die Seite neu zu laden (wie im Monitor)
async softRefresh() {
await this.loadIncidents();
if (!this.incident) return;
const openBefore = this.openTabs.slice();
const activeBefore = this.activeTab;
await this.loadDetail(this.incident.id);
// geöffnete Tabs wiederherstellen (loadDetail öffnet sonst nur das Lagebild)
if (openBefore.length) {
this.openTabs = [];
document.getElementById('center-tabbar').innerHTML = '';
openBefore.forEach(k => this.openTab(k));
if (activeBefore && this.openTabs.includes(activeBefore)) this.activateTab(activeBefore);
}
UI.showToast('Aktualisiert', 'info');
},
_showStatus(text) {
const bar = document.getElementById('live-status');
document.getElementById('live-status-text').textContent = text;
bar.classList.add('active');
if (!this._startTime) this._startTime = Date.now();
if (!this._timerInt) {
this._timerInt = setInterval(() => {
const s = Math.floor((Date.now() - this._startTime) / 1000);
document.getElementById('live-status-timer').textContent =
Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}, 1000);
}
},
_hideStatus() {
document.getElementById('live-status').classList.remove('active');
if (this._timerInt) { clearInterval(this._timerInt); this._timerInt = null; }
this._startTime = null;
document.getElementById('live-status-timer').textContent = '';
},
_wireWs() {
const forCurrent = (msg) => this.incident && String(msg.incident_id) === String(this.incident.id);
WS.on('status_update', (msg) => {
if (!forCurrent(msg)) return;
const label = (typeof UI._getStepLabel === 'function') ? UI._getStepLabel(msg.status) : (msg.status || '');
this._showStatus(msg.detail || label || 'Aktualisierung läuft ...');
});
WS.on('pipeline_step', (msg) => {
if (!forCurrent(msg)) return;
if (msg.label || msg.step) this._showStatus(msg.label || msg.step);
});
WS.on('refresh_complete', (msg) => {
if (!forCurrent(msg)) return;
const wasCollect = this._runningStage === 'collect';
this._hideStatus();
this._stagesIdle();
UI.showToast(wasCollect ? 'Sammeln abgeschlossen' : 'Aktualisierung abgeschlossen', 'info');
this.loadDetail(this.incident.id);
this.loadIncidents();
});
WS.on('refresh_error', (msg) => {
if (!forCurrent(msg)) return;
this._hideStatus();
this._stagesIdle();
UI.showToast('Aktualisierung fehlgeschlagen', 'error');
});
WS.on('refresh_cancelled', (msg) => {
if (!forCurrent(msg)) return;
this._hideStatus();
this._stagesIdle();
UI.showToast('Abgebrochen', 'info');
});
},
async syncRefreshStatus() {
// Nach WS-Reconnect: laufende Refreshes abgleichen
try {
const running = await API.getRefreshingIncidents();
const ids = (running || []).map(r => String(r.incident_id || r.id));
if (this.incident && ids.includes(String(this.incident.id))) {
this._showStatus('Aktualisierung läuft ...');
// Welcher der beiden Orchestrator-Bausteine läuft, sagt uns der Server nicht;
// ohne bekannten Zustand als "Kompletter Lauf" anzeigen (beide sind abbrechbar).
this._stageRunning(this._runningStage || 'full', 'läuft …');
} else {
this._hideStatus();
if (this._runningStage === 'collect' || this._runningStage === 'full') this._stagesIdle();
}
} catch (e) { /* ignore */ }
},
// === Neuen Fall anlegen (gleicher Funktionsumfang wie im Dashboard) ===
openNewIncident() {
const form = document.getElementById('new-incident-form');
form.reset();
document.getElementById('inc-visibility').checked = true;
document.getElementById('btn-enhance-description').disabled = true;
this.incTypeDefaults();
this.incRefreshToggle();
this.incVisibilityHint();
this._gateXToggle();
const m = document.getElementById('modal-new');
m.classList.add('active');
setTimeout(() => document.getElementById('inc-title').focus(), 50);
},
closeNewIncident() {
document.getElementById('modal-new').classList.remove('active');
if (this._enhanceController) { this._enhanceController.abort(); this._enhanceController = null; }
},
// X-Kanäle nur freigeben, wenn ein X-Zugang hinterlegt ist (wie im Dashboard)
async _gateXToggle() {
const cb = document.getElementById('inc-x');
const hint = document.getElementById('inc-x-hint');
if (!cb) return;
let active = 0;
try {
const accounts = await API.listXAccounts();
active = (accounts || []).filter(a => a.active).length;
} catch (e) {
active = 0; // im Zweifel sperren
}
cb.disabled = active === 0;
if (active === 0) cb.checked = false;
if (hint) hint.style.display = active === 0 ? '' : 'none';
},
incTitleChanged() {
const t = document.getElementById('inc-title').value.trim();
document.getElementById('btn-enhance-description').disabled = t.length < 3;
},
incTypeDefaults() {
const type = document.getElementById('inc-type').value;
const hint = document.getElementById('type-hint');
const mode = document.getElementById('inc-refresh-mode');
if (type === 'research') {
hint.textContent = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.';
mode.value = 'manual';
} else {
hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.';
}
this.incRefreshToggle();
},
incRefreshToggle() {
const auto = document.getElementById('inc-refresh-mode').value === 'auto';
document.getElementById('refresh-interval-field').classList.toggle('visible', auto);
document.getElementById('refresh-starttime-field').classList.toggle('visible', auto);
},
incIntervalMin() {
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
const input = document.getElementById('inc-refresh-value');
const min = unit === 1 ? 10 : 1;
input.min = min;
if (parseInt(input.value) < min) input.value = min;
},
incVisibilityHint() {
const pub = document.getElementById('inc-visibility').checked;
document.getElementById('visibility-text').textContent = pub
? 'Öffentlich : für alle Nutzer sichtbar'
: 'Privat : nur für dich sichtbar';
},
async generateDescription() {
const title = document.getElementById('inc-title').value.trim();
const description = document.getElementById('inc-description').value.trim();
const type = document.getElementById('inc-type').value;
const btn = document.getElementById('btn-enhance-description');
const btnText = document.getElementById('enhance-btn-text');
const spinner = document.getElementById('enhance-spinner');
const area = document.getElementById('inc-description');
if (title.length < 3) return;
if (this._enhanceController) this._enhanceController.abort();
this._enhanceController = new AbortController();
btn.disabled = true;
btnText.textContent = 'Wird generiert …';
spinner.style.display = '';
area.readOnly = true;
try {
const res = await API.enhanceDescription(title, description || null, type, this._enhanceController.signal);
area.value = res.description || '';
} catch (err) {
if (err && err.name !== 'AbortError') {
let msg = 'Beschreibung konnte nicht generiert werden';
if (err.status === 503) msg = 'KI-Zugang aktuell nicht verfügbar.';
else if (err.status === 429) msg = 'KI ist gerade ausgelastet. Bitte kurz warten.';
else if (err.status === 504) msg = 'KI antwortet gerade nicht. Bitte erneut versuchen.';
UI.showToast(msg, 'error');
}
} finally {
spinner.style.display = 'none';
btnText.textContent = 'Beschreibung generieren';
area.readOnly = false;
btn.disabled = title.length < 3;
this._enhanceController = null;
}
},
_incidentFormData() {
const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
const auto = document.getElementById('inc-refresh-mode').value === 'auto';
return {
title: document.getElementById('inc-title').value.trim(),
description: document.getElementById('inc-description').value.trim() || null,
type: document.getElementById('inc-type').value,
refresh_mode: document.getElementById('inc-refresh-mode').value,
refresh_interval: Math.max(10, Math.min(10080, value * unit)),
refresh_start_time: auto ? (document.getElementById('inc-refresh-starttime').value || null) : null,
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').checked,
include_x: document.getElementById('inc-x').checked,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
};
},
async submitIncident(ev) {
ev.preventDefault();
const btn = document.getElementById('modal-new-submit');
const titleEl = document.getElementById('inc-title');
if (!titleEl.value.trim()) { titleEl.focus(); return; }
btn.disabled = true;
try {
const inc = await API.createIncident(this._incidentFormData());
// E-Mail-Abo wie im Dashboard mitspeichern
try {
await API.updateSubscription(inc.id, {
notify_email_summary: document.getElementById('inc-notify-summary').checked,
notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
});
} catch (e) { /* Abo ist Beiwerk - der Fall steht */ }
this.closeNewIncident();
await this.loadIncidents();
await this.selectIncident(inc.id);
UI.showToast('Fall angelegt', 'success');
} catch (e) {
UI.showToast((e && e.message) || 'Fall konnte nicht angelegt werden', 'error');
} finally {
btn.disabled = false;
}
},
// === Export ===
async doExport() {
if (!this.incident) return;
const format = document.getElementById('export-format').value;
const sections = ['zusammenfassung', 'bericht', 'faktencheck', 'quellen'];
try {
const resp = await API.exportReport(this.incident.id, format, null, sections);
if (!resp.ok) throw new Error('Export fehlgeschlagen');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Lagebericht_${this.incident.id}.${format}`;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
} catch (e) {
UI.showToast('Export fehlgeschlagen', 'error');
}
},
// === Theme ===
toggleTheme() {
const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const next = cur === 'light' ? 'dark' : 'light';
if (next === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
localStorage.setItem('osint_theme', next);
if (UI.updateMapTheme) UI.updateMapTheme();
},
// === Zeit-Helfer ===
_ts(a) {
const d = parseUTC(a.published_at || a.collected_at || a.created_at);
return d ? d.getTime() : 0;
},
_fmtDate(a, withTime) {
const d = parseUTC(a.published_at || a.collected_at || a.created_at);
return this._fmt(d, withTime);
},
_fmt(d, withTime) {
if (!d) return '';
const opts = withTime
? { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }
: { day: '2-digit', month: '2-digit' };
return d.toLocaleString('de-DE', opts);
},
};
document.addEventListener('DOMContentLoaded', () => Studio.init());
// F5: Daten aktualisieren statt Seite neu laden (wie im Monitor)
document.addEventListener('keydown', (e) => {
if (e.key === 'F5') {
e.preventDefault();
Studio.softRefresh();
}
// ESC schließt Rueckfrage, Anlege-Dialog, Kontextmenue bzw. den Auswahlmodus
if (e.key === 'Escape') {
const c = document.getElementById('modal-confirm');
if (c && c.classList.contains('active')) { Studio._confirmClose(false); return; }
const m = document.getElementById('modal-new');
if (m && m.classList.contains('active')) Studio.closeNewIncident();
Studio._closeCaseMenu();
if (Studio.caseMode !== null) Studio.endCaseMode();
}
});