Dateien
AegisSight-Monitor/src/static/js/studio.js
claude-dev 896791608a refactor(studio): Automatisch aktualisieren raus aus dem Startdialog
Die Wahl war keine Startaktion, sondern eine Einstellung, die nebenbei einen
Lauf auslöste. Damit stand sie neben zwei echten Alternativen und beantwortete
eine andere Frage als die gestellte.

Das Intervall samt Startzeit steht im Reiter Einstellungen des Falls, dort
gehört es hin und dort lässt es sich auch wieder ändern. Im Startdialog
bleiben zwei Alternativen, die sich wirklich ausschließen. Den Fall aus dem
Netz aufbauen oder mit eigenem Material beginnen.

Fünf Prüfungen halten das fest, darunter der Nachweis, dass die automatische
Aktualisierung im Einstellungen-Reiter erreichbar bleibt.

Cache-Buster auf 20260802d.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:36:50 +00:00

2570 Zeilen
123 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: '',
caseScope: 'all', // 'all' | 'mine' - wie die Seitenleiste im klassischen Dashboard
_me: null, // Antwort von /auth/me (fuer Konto-Menue und "Eigene")
_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 {
this._me = await API.getMe();
// Studio ist vorerst nur fuer info@aegis-sight.de freigegeben.
if (!this._me || this._me.email !== 'info@aegis-sight.de') { window.location.href = '/dashboard'; return; }
} catch (e) { /* 401 leitet in api.js um */ }
this._initHeader();
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');
}
},
/**
* Kopfzeile rechts aufbauen. Bewusst derselbe Umfang wie im klassischen
* Dashboard, damit man beim Wechsel der Ansicht nichts verliert.
* Barrierefreiheit, Theme-Schalter, Konto-Menue und Abmelden.
*/
_initHeader() {
// Barrierefreiheits-Knopf haengt sich selbst vor den Theme-Schalter
if (window.A11yManager || typeof A11yManager !== 'undefined') A11yManager.init();
// Theme-Schalter kennt zwei Zustaende ueber seine Klasse
this._syncThemeSwitch();
const user = this._me;
if (!user) return;
const userEl = document.getElementById('header-user');
if (userEl) userEl.textContent = user.email || '';
const orgEl = document.getElementById('header-org-name');
if (orgEl) orgEl.textContent = user.org_name || '-';
const licEl = document.getElementById('header-license-info');
if (licEl) {
const labels = { trial: 'Trial', annual: 'Jahreslizenz', permanent: 'Permanent' };
licEl.textContent = user.read_only
? 'Abgelaufen'
: (labels[user.license_type] || user.license_status || '-');
}
// Credits nur zeigen, wenn die Lizenz welche fuehrt
const creditsSection = document.getElementById('credits-section');
if (creditsSection && user.credits_total) {
creditsSection.style.display = 'block';
const percentUsed = user.credits_percent_used || 0;
const percentRemaining = Math.max(0, 100 - percentUsed);
const bar = document.getElementById('credits-bar');
document.getElementById('credits-remaining').textContent = (user.credits_remaining || 0).toLocaleString('de-DE');
document.getElementById('credits-total').textContent = (user.credits_total || 0).toLocaleString('de-DE');
bar.style.width = percentRemaining + '%';
bar.classList.remove('warning', 'critical');
if (percentUsed > 80) bar.classList.add('critical');
else if (percentUsed > 50) bar.classList.add('warning');
const pct = document.getElementById('credits-percent');
if (pct) pct.textContent = percentRemaining.toFixed(0) + '% verbleibend';
}
// Konto-Menue auf- und zuklappen
const btn = document.getElementById('header-user-btn');
const dropdown = document.getElementById('header-user-dropdown');
if (btn && dropdown) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
btn.setAttribute('aria-expanded', String(isOpen));
});
dropdown.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => {
dropdown.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
});
}
},
logout() {
localStorage.removeItem('osint_token');
localStorage.removeItem('osint_username');
localStorage.removeItem('studio_incident');
WS.disconnect();
window.location.href = '/';
},
/**
* 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';
// Ohne Fall wuerde die Lauf-Anzeige sonst mit dem Stand des zuletzt
// geoeffneten Falls stehen bleiben. Nur beim Leerschalten neu zeichnen,
// beim Oeffnen macht das loadDetail mit den richtigen Daten.
if (on) {
this.incident = null;
this.fresh = null;
this._renderLauf();
}
},
// === 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();
},
// "Alle" oder nur die selbst angelegten Fälle, wie in der Seitenleiste
// des klassischen Dashboards.
setCaseScope(scope) {
this.caseScope = scope === 'mine' ? 'mine' : 'all';
document.querySelectorAll('.case-scope .sidebar-filter-btn').forEach(btn => {
const on = btn.dataset.scope === this.caseScope;
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
});
this.renderCases();
},
// Trifft der Fall den aktuellen Suchtext und die Auswahl "Alle"/"Eigene"?
_caseMatches(i) {
const q = this.caseFilter;
if (q && !(i.title || '').toLowerCase().includes(q)) return false;
if (this.caseScope === 'mine') {
const me = this._me && this._me.email;
if (!me || i.created_by_username !== me) return false;
}
return true;
},
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() {
return this.incidents.filter(i => this._caseMatches(i) && (i.status === 'active' || this._archiveOpen));
},
renderCases() {
const list = document.getElementById('cases-list');
if (!list) return;
const all = this.incidents.filter(i => this._caseMatches(i));
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>
${Studio._aiTag(i, 'case-ai-tag')}
<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)
// Zähler am Reiter zeigt, was die Liste gerade zeigt (also inkl. "Eigene")
const cnt = document.getElementById('cases-count');
if (cnt) cnt.textContent = all.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;
// Der Stand-Kasten und die Startfrage sind dieselbe Aktion, nie beide
// gleichzeitig zeigen. Wird die Frage nicht mehr gestellt, ist der Kasten
// wieder da, sonst stuende man nach einem ergebnislosen Lauf ohne
// Startmoeglichkeit da. Die Kette darunter bleibt sichtbar, sie erklaert,
// was der Startdialog anbietet.
const runAll = document.getElementById('lauf-stand');
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.'
: 'Artikel holen, Lagebild schreiben, Fakten prüfen und Orte erkennen, nacheinander. Am Ende steht ein fertiges Lagebild.';
}
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.getElementById('lauf-stand');
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;
}
// Bleibt 'full'. Einzelne Schritte lassen sich nicht mehr starten, und
// die automatische Aktualisierung ist eine Einstellung, keine Startaktion.
// Sie steht im Reiter Einstellungen des Falls.
this.runStage('full');
},
// === 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');
// Beschriftung in die span, sonst wuerde textContent das Icon entfernen.
const setzeText = (el, text) => {
if (!el) return;
const ziel = el.querySelector('span');
if (ziel) ziel.textContent = text; else el.textContent = text;
};
if (ba) { ba.hidden = mode !== 'archive' || !anyActive; setzeText(ba, `Archivieren (${sel.filter(i => i.status === 'active').length})`); }
if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; setzeText(bv, `Aktivieren (${sel.filter(i => i.status !== 'active').length})`); }
if (bd) { bd.hidden = mode !== 'delete' || n === 0; setzeText(bd, `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 = '';
// KI-Weg nur zeigen, wenn der Fall ausdruecklich darauf steht. Ohne
// Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet.
const aiBadge = document.getElementById('studio-ai-badge');
if (aiBadge) aiBadge.innerHTML = this._aiTag(inc, 'incident-ai-badge');
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 ===
// === Einstellungen des Falls =========================================
// Der Reiter ersetzt das Bearbeiten-Modal der klassischen Ansicht. Er
// schickt beim Speichern nur, was sich wirklich geaendert hat. Zwei Gruende.
// Der Server aktualisiert feldweise, alles Mitgeschickte wuerde sonst
// ueberschrieben, auch wenn es nur unveraendert zurueckgereicht wird. Und
// eine unbeabsichtigte Aenderung am Intervall kann einen laufenden Zeitplan
// verstellen.
_setEl(id) { return document.getElementById(id); },
/** Liest das Formular. Der Rueckgabewert ist mit dem Schnappschuss vergleichbar. */
_settingsWerte() {
const g = (id) => this._setEl(id);
const einheit = parseInt(g('set-refresh-unit').value) || 1;
const zahl = parseInt(g('set-refresh-value').value) || 15;
const auto = g('set-refresh-mode').value === 'auto';
const werte = {
title: g('set-title').value.trim(),
description: g('set-description').value.trim(),
international_sources: g('set-international').checked,
include_telegram: g('set-telegram').checked,
include_x: g('set-x').checked,
visibility: g('set-visibility').checked ? 'public' : 'private',
ai_backend: g('set-ai-backend').value || '',
refresh_mode: g('set-refresh-mode').value,
refresh_interval: Math.max(10, Math.min(10080, zahl * einheit)),
retention_days: Math.max(0, Math.min(999, parseInt(g('set-retention').value) || 0)),
};
// Die Startzeit gehoert nur zur automatischen Aktualisierung. Bei
// manueller Aktualisierung bleibt der gespeicherte Wert unangetastet,
// ausgewertet wird er dort ohnehin nicht.
if (auto) werte.refresh_start_time = g('set-refresh-starttime').value || '07:00';
return werte;
},
_settingsAbo() {
return {
notify_email_summary: this._setEl('set-notify-summary').checked,
notify_email_new_articles: this._setEl('set-notify-new-articles').checked,
notify_email_status_change: this._setEl('set-notify-status-change').checked,
};
},
/** Fuellt das Formular aus dem offenen Fall. Dient auch als Verwerfen. */
async loadSettings() {
const inc = this.incident;
if (!inc || !this._setEl('set-title')) return;
const g = (id) => this._setEl(id);
g('set-title').value = inc.title || '';
g('set-description').value = inc.description || '';
g('set-international').checked = !!inc.international_sources;
g('set-telegram').checked = !!inc.include_telegram;
g('set-x').checked = !!inc.include_x;
g('set-visibility').checked = (inc.visibility || 'public') === 'public';
g('set-ai-backend').value = inc.ai_backend || '';
g('set-refresh-mode').value = inc.refresh_mode === 'auto' ? 'auto' : 'manual';
g('set-refresh-starttime').value = inc.refresh_start_time || '07:00';
g('set-retention').value = (inc.retention_days === null || inc.retention_days === undefined)
? 0 : inc.retention_days;
// Intervall in die groesste Einheit zeigen, die glatt aufgeht. 1440
// Minuten liest sich als 1 Tag besser denn als 1440 Minuten.
const iv = parseInt(inc.refresh_interval) || 15;
let einheit = 1;
for (const u of [10080, 1440, 60]) { if (iv % u === 0) { einheit = u; break; } }
g('set-refresh-unit').value = String(einheit);
g('set-refresh-value').value = String(Math.round(iv / einheit));
this.settingsRefreshToggle();
this.settingsIntervalMin();
this.settingsVisibilityHint();
this._settingsRunningNote();
// Der Schnappschuss ist die Vergleichsgrundlage fuers Speichern. Er
// wird vor dem Abo-Nachladen gesetzt, damit ein langsamer Abruf das
// Formular nicht in einem halben Zustand zuruecklaesst.
this._settingsAlt = this._settingsWerte();
this.settingsAiChanged();
this._settingsState('');
try {
const abo = await API.getSubscription(inc.id);
g('set-notify-summary').checked = !!(abo && abo.notify_email_summary);
g('set-notify-new-articles').checked = !!(abo && abo.notify_email_new_articles);
g('set-notify-status-change').checked = !!(abo && abo.notify_email_status_change);
} catch (e) {
// Ohne Abo-Stand bleiben die Haken aus. Das ist der Serverzustand
// fuer einen Fall ohne Abo.
g('set-notify-summary').checked = false;
g('set-notify-new-articles').checked = false;
g('set-notify-status-change').checked = false;
}
this._settingsAboAlt = this._settingsAbo();
// Bewusst abgewartet. Erst danach ist das Formular vollstaendig,
// vorher stuende der X-Schalter noch auf dem Ausgangswert.
await this._settingsXHint();
},
settingsRefreshToggle() {
const auto = this._setEl('set-refresh-mode').value === 'auto';
this._setEl('set-interval-field').classList.toggle('visible', auto);
this._setEl('set-starttime-field').classList.toggle('visible', auto);
},
settingsIntervalMin() {
const einheit = parseInt(this._setEl('set-refresh-unit').value);
const feld = this._setEl('set-refresh-value');
const min = einheit === 1 ? 10 : 1;
feld.min = min;
if (parseInt(feld.value) < min) feld.value = min;
},
settingsVisibilityHint() {
const oeffentlich = this._setEl('set-visibility').checked;
this._setEl('set-visibility-text').textContent = oeffentlich
? 'Öffentlich, für alle Nutzer sichtbar'
: 'Privat, nur für dich sichtbar';
},
/** Warnt, sobald der KI-Weg vom gespeicherten Stand abweicht. */
settingsAiChanged() {
const w = this._setEl('set-ai-warn');
if (!w) return;
const jetzt = this._setEl('set-ai-backend').value || '';
const alt = (this._settingsAlt && this._settingsAlt.ai_backend) || '';
w.hidden = jetzt === alt;
},
_settingsRunningNote() {
const n = this._setEl('set-running-note');
if (n) n.hidden = !this._runningStage;
},
_settingsState(text, art) {
const s = this._setEl('set-state');
if (!s) return;
s.textContent = text || '';
s.className = 'settings-state' + (art ? ' ' + art : '');
},
/** X laesst sich nur einschalten, wenn ein Zugang hinterlegt ist. */
async _settingsXHint() {
const cb = this._setEl('set-x');
const hint = this._setEl('set-x-hint');
if (!cb) return;
let aktiv = 0;
try {
const konten = await API.listXAccounts();
aktiv = (konten || []).filter(a => a.active).length;
} catch (e) {
aktiv = 0; // im Zweifel sperren
}
// Nutzt der Fall X bereits, bleibt der Schalter bedienbar. Sonst koennte
// man ihn nach dem Wegfall des letzten Zugangs nicht mehr abschalten.
cb.disabled = aktiv === 0 && !cb.checked;
if (hint) hint.hidden = aktiv !== 0;
},
async saveSettings() {
const inc = this.incident;
if (!inc || !this._setEl('set-title')) return;
const knopf = this._setEl('set-save');
const jetzt = this._settingsWerte();
if (!jetzt.title) {
UI.showToast('Ohne Titel geht es nicht', 'error');
this._setEl('set-title').focus();
return;
}
const alt = this._settingsAlt || {};
const patch = {};
Object.keys(jetzt).forEach(k => {
if (jetzt[k] !== alt[k]) patch[k] = jetzt[k];
});
const abo = this._settingsAbo();
const aboAlt = this._settingsAboAlt || {};
const aboGeaendert = Object.keys(abo).some(k => abo[k] !== aboAlt[k]);
if (!Object.keys(patch).length && !aboGeaendert) {
this._settingsState('Nichts geändert');
return;
}
knopf.disabled = true;
this._settingsState('Wird gespeichert …');
try {
if (Object.keys(patch).length) {
const neu = await API.updateIncident(inc.id, patch);
// Der Server antwortet mit der angereicherten Lage. Sie ist die
// verlaessliche Quelle, das Formular koennte abweichen.
this.incident = (neu && neu.id) ? neu : Object.assign({}, inc, patch);
const i = this.incidents.findIndex(x => x.id === inc.id);
if (i >= 0) this.incidents[i] = Object.assign({}, this.incidents[i], this.incident);
}
if (aboGeaendert) await API.updateSubscription(inc.id, abo);
this._settingsAlt = this._settingsWerte();
this._settingsAboAlt = abo;
this.settingsAiChanged();
this.renderHeader(this.incident);
this.renderCases();
this._settingsState('Gespeichert', 'ok');
UI.showToast('Einstellungen gespeichert', 'success');
} catch (e) {
this._settingsState('Nicht gespeichert', 'fehler');
UI.showToast((e && e.message) || 'Einstellungen konnten nicht gespeichert werden', 'error');
} finally {
knopf.disabled = false;
}
},
/** Kennzeichen des KI-Wegs. Leer, solange die Org-Vorgabe gilt. */
_aiTag(inc, klasse) {
const b = inc && inc.ai_backend;
if (b === 'bedrock') {
return `<span class="${klasse} ai-eu" title="KI-Verarbeitung in der EU (AWS Bedrock, Frankfurt), Recherche über den europäischen Suchindex staan">EU</span>`;
}
if (b === 'cli') {
return `<span class="${klasse} ai-anthropic" title="KI-Verarbeitung über Anthropic mit eingebauter Websuche (heutiger Weg)">Anthropic</span>`;
}
return '';
},
_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();
if (key === 'settings') this.loadSettings();
},
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._renderLauf(); } 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);
},
// === Der Lauf (Spalte 3) =============================================
// Es gibt genau einen Startpunkt. Die vier Schritte darunter sind Anzeige,
// sie starten nichts. Aufklappen erklaert nur, was ein Schritt tut.
//
// Der Server kann einzelne Bausteine weiterhin starten (POST .../stage).
// Die Oberflaeche nutzt das nicht mehr, zeigt einen so gestarteten Lauf
// aber korrekt an, denn er kann aus der klassischen Ansicht kommen.
_CANCELLABLE: ['collect', 'full'],
// 'network' hat keine Karte mehr, das Label bleibt trotzdem stehen. Kaeme
// der Baustein im Server, meldete ein laufender Schritt sonst nur seinen
// technischen Schluessel.
_STAGE_LABELS: {
collect: 'Artikel holen', analyze: 'Lagebild schreiben',
factcheck: 'Fakten prüfen', geoparse: 'Orte erkennen',
network: 'Netzwerkanalyse', full: 'Kompletter Lauf',
},
_SCHRITT_ICONS: {
collect: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
analyze: '<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"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
factcheck: '<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/>',
geoparse: '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
},
_SCHRITT_WAS: {
collect: 'Durchsucht die ausgewählten Quellen nach neuen Meldungen. Ohne diesen Schritt haben die folgenden nichts, womit sie arbeiten können.',
analyze: 'Wertet alle Artikel aus und schreibt daraus den zusammenhängenden Bericht. Der bisherige Bericht bleibt im Verlauf erhalten.',
factcheck: 'Zieht die Tatsachenbehauptungen aus dem Bericht und sucht nach Belegen und Widersprüchen. Der bisherige Stand wird als Lauf archiviert.',
geoparse: 'Findet Ortsangaben in den Artikeln und setzt sie auf die Karte. Läuft nur über Artikel, die noch nicht geprüft wurden.',
},
_schrittOffen: null,
/** Von aussen (Tastatur, WebSocket-Resync) heisst Aktualisieren kompletter Lauf. */
async refresh() { return this.runStage('full'); },
/**
* Der eine Startknopf. Laeuft gerade ein abbrechbarer Schritt, bricht er ab,
* sonst startet er den kompletten Lauf.
*
* Wichtig ist der laufende Schritt, nicht 'full'. Ein Lauf kann aus der
* klassischen Ansicht als 'collect' gestartet worden sein. Ein Abbruch auf
* 'full' wuerde ihn dann nicht treffen, obwohl der Knopf Abbrechen anzeigt.
*/
async laufKnopf() {
const run = this._runningStage;
if (run && this._CANCELLABLE.includes(run)) return this.runStage(run);
if (run) {
UI.showToast('Dieser Schritt lässt sich nicht abbrechen.', 'info');
return;
}
return this.runStage('full');
},
_stageEls() { return Array.from(document.querySelectorAll('[data-stage]')); },
_stageEl(stage) { return document.querySelector(`[data-stage="${CSS.escape(stage)}"]`); },
_stagesIdle() {
this._runningStage = null;
this._stageEls().forEach(b => {
b.classList.remove('running');
b.disabled = !this.incident;
});
this._renderLauf();
},
_stageRunning(stage, text) {
if (!stage) return;
this._runningStage = stage;
// Sobald etwas läuft, ist die Startfrage beantwortet.
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 => {
b.classList.add('running');
// Der Startknopf bleibt nur klickbar, wenn sich der Lauf abbrechen lässt
b.disabled = !cancellable;
});
this._renderLauf(text);
},
klappSchritt(i) {
this._schrittOffen = (this._schrittOffen === i) ? null : i;
this._renderLauf();
},
// 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]}` : '';
},
_svg(pfad, groesse) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${groesse}" height="${groesse}" `
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
+ `stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${pfad}</svg>`;
},
/**
* Zustand der vier Schritte aus /freshness ableiten.
* 'fertig' | 'veraltet' | 'offen' | 'gesperrt' | 'laeuft' | 'wartet'
*/
_schrittZustaende() {
const f = this.fresh || {};
const run = this._runningStage;
const arts = f.articles || 0;
const s = f.summary || {};
const fc = f.factcheck || {};
const geo = f.geoparse || {};
const zust = (key, ohneLauf) => {
if (run === key) return 'laeuft';
// Der komplette Lauf und das Sammeln arbeiten alle Schritte ab
if (run === 'full' || run === 'collect') return 'wartet';
if (run) return 'wartet';
return ohneLauf();
};
return [
{
key: 'collect',
zustand: zust('collect', () => (arts ? 'fertig' : 'offen')),
stand: run === 'collect' ? 'läuft gerade'
: arts ? `${arts} Artikel${f.last_collect ? ', ' + this._fmtStamp(f.last_collect) : ''}`
: 'noch nicht gelaufen',
},
{
key: 'analyze',
zustand: zust('analyze', () => !arts ? 'gesperrt'
: !s.exists ? 'offen'
: s.pending > 0 ? 'veraltet' : 'fertig'),
stand: run === 'analyze' ? 'läuft gerade'
: !arts ? 'wartet auf Artikel'
: !s.exists ? 'noch nicht erzeugt'
: s.pending > 0 ? `${s.pending} neue Artikel seit dem Bericht`
: `aktuell, ${this._fmtStamp(s.last)}`,
},
{
key: 'factcheck',
zustand: zust('factcheck', () => !arts ? 'gesperrt'
: !fc.exists ? 'offen'
: fc.pending > 0 ? 'veraltet' : 'fertig'),
stand: run === 'factcheck' ? 'läuft gerade'
: !arts ? 'wartet auf Artikel'
: !fc.exists ? 'noch nicht erzeugt'
: fc.pending > 0 ? `${fc.pending} neue Artikel seit der Prüfung`
: `${fc.facts} Fakten geprüft`,
},
{
key: 'geoparse',
zustand: zust('geoparse', () => !arts ? 'gesperrt'
: geo.pending > 0 ? 'veraltet' : 'fertig'),
stand: run === 'geoparse' ? 'läuft gerade'
: !arts ? 'wartet auf Artikel'
: geo.pending > 0 ? `${geo.pending} Artikel noch nicht verortet`
: 'alle Artikel verortet',
},
];
},
/** Stand-Kasten, Kette und Schrittliste zeichnen. */
_renderLauf(runText) {
const kasten = document.getElementById('lauf-stand');
if (!kasten) return;
const inc = this.incident;
const f = this.fresh || {};
const run = this._runningStage;
const arts = f.articles || 0;
const research = inc && inc.type === 'research';
const haupt = document.getElementById('ls-haupt');
const neben = document.getElementById('ls-neben');
const knopf = document.getElementById('ls-btn');
const text = knopf && knopf.querySelector('.ls-text');
const iconEl = knopf && knopf.querySelector('.ls-icon');
const hinweis = document.getElementById('ls-hinweis');
const ICON_LAUF = '<path d="M3 12a9 9 0 0 1 15.5-6.4L21 8"/><polyline points="21 3 21 8 16 8"/><path d="M21 12a9 9 0 0 1-15.5 6.4L3 16"/><polyline points="3 21 3 16 8 16"/>';
const ICON_STOPP = '<rect width="12" height="12" x="6" y="6" rx="1"/>';
// --- Stand-Kasten ---
let ueberschrift = '';
let knopfText = 'Aktualisieren';
let abbruch = false;
let betont = false;
if (!inc) {
ueberschrift = 'Kein Fall geöffnet';
} else if (run) {
ueberschrift = (this._STAGE_LABELS[run] || 'Ein Schritt') + ' läuft gerade';
abbruch = this._CANCELLABLE.includes(run);
knopfText = abbruch ? 'Abbrechen' : (runText || 'läuft …');
} else if (!arts) {
ueberschrift = 'Noch keine Artikel gesammelt';
knopfText = research ? 'Recherche starten' : 'Lauf starten';
betont = true;
} else {
const offen = this._schrittZustaende()
.filter(s => s.zustand === 'veraltet' || s.zustand === 'offen');
if (offen.length) {
const s = f.summary || {};
ueberschrift = s.pending > 0
? `${s.pending} neue Artikel seit dem letzten Bericht`
: 'Ergebnisse sind nicht auf dem neuesten Stand';
knopfText = 'Jetzt aktualisieren';
betont = true;
} else {
ueberschrift = 'Alle Ergebnisse auf dem neuesten Stand';
knopfText = 'Erneut durchlaufen';
}
}
kasten.classList.toggle('betont', betont);
if (haupt) {
haupt.textContent = ueberschrift;
haupt.classList.toggle('warn', betont && !!arts);
}
if (neben) {
neben.textContent = !inc ? ''
: arts ? `${arts} Artikel im Bestand`
: 'Noch kein Bestand';
}
if (text) text.textContent = knopfText;
if (iconEl) iconEl.innerHTML = this._svg(abbruch ? ICON_STOPP : ICON_LAUF, 16);
if (knopf) {
knopf.classList.toggle('abbrechen', abbruch);
knopf.disabled = !inc || (!!run && !abbruch);
knopf.title = abbruch ? 'Laufenden Schritt abbrechen'
: research ? 'Alle Schritte nacheinander, am Ende steht ein fertiger Recherchebericht'
: 'Alle Schritte nacheinander, am Ende steht ein fertiges Lagebild';
}
if (hinweis) {
hinweis.textContent = (!inc || run || arts) ? ''
: 'Danach entstehen Bericht, Faktencheck und Karte von selbst.';
hinweis.hidden = !hinweis.textContent;
}
// --- Kette und Schritte ---
const schritte = inc ? this._schrittZustaende() : [];
const kette = document.getElementById('lauf-kette');
const fortschritt = document.getElementById('lauf-fortschritt');
const liste = document.getElementById('lauf-schritte');
if (kette) {
kette.innerHTML = schritte.map((s, i) => {
const kn = `<span class="kn kn-${s.zustand}"></span>`;
if (i === schritte.length - 1) return kn;
const voll = s.zustand === 'fertig' && schritte[i + 1].zustand === 'fertig';
return kn + `<span class="kn-linie${voll ? ' aktiv' : ''}"></span>`;
}).join('');
}
if (fortschritt) {
const fertig = schritte.filter(s => s.zustand === 'fertig').length;
fortschritt.textContent = inc
? `${fertig} von ${schritte.length} Schritten auf dem neuesten Stand` : '';
}
if (liste) {
liste.innerHTML = schritte.map((s, i) => {
const auf = this._schrittOffen === i;
const name = (s.key === 'analyze' && research)
? 'Recherchebericht schreiben' : this._STAGE_LABELS[s.key];
const marke = s.zustand === 'fertig'
? this._svg('<path d="M20 6 9 17l-5-5"/>', 12) : String(i + 1);
return `<div class="schritt schritt-${s.zustand}${auf ? ' offen' : ''}">
<button class="schritt-kopf" type="button" onclick="Studio.klappSchritt(${i})"
aria-expanded="${auf}">
<span class="schritt-nr">${marke}</span>
<span class="schritt-txt">
<span class="schritt-name">${UI.escape(name)}</span>
<span class="schritt-stand">${UI.escape(s.stand)}</span>
</span>
<span class="schritt-pfeil">${this._svg('<polyline points="6 9 12 15 18 9"/>', 13)}</span>
</button>
<div class="schritt-mehr">${UI.escape(this._SCHRITT_WAS[s.key] || '')}</div>
</div>`;
}).join('');
}
// --- Nebenangaben an den Ansichten ---
this._renderMeta();
},
/** Zusatzangaben an den Ansichtsknoepfen, aus /freshness. */
_renderMeta() {
const f = this.fresh;
if (!f) return;
const arts = f.articles || 0;
const s = f.summary || {};
const fc = f.factcheck || {};
const setz = (id, wert) => {
const el = document.getElementById(id);
if (el) el.textContent = wert || '';
};
setz('art-summary-meta', s.exists
? `${arts} Artikel ausgewertet, ${this._fmtStamp(s.last)}` : 'noch nicht erzeugt');
setz('art-fc-meta', fc.exists
? `${fc.facts} Fakten, ${this._fmtStamp(fc.last)}` : 'noch nicht erzeugt');
const snap = document.getElementById('art-snap-meta');
if (snap && !snap.textContent) snap.textContent = f.snapshots ? String(f.snapshots) : '';
const tl = document.getElementById('art-tl-meta');
if (tl && !tl.textContent && f.events) tl.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',
ai_backend: (document.getElementById('inc-ai-backend') || {}).value || '',
};
},
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);
this._syncThemeSwitch();
if (UI.updateMapTheme) UI.updateMapTheme();
},
// Der Schiebeschalter aus dem klassischen Dashboard zeigt seinen Zustand ueber
// die Klasse 'dark' bzw. 'light'.
_syncThemeSwitch() {
const el = document.getElementById('theme-toggle');
if (!el) return;
const theme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
el.classList.remove('dark', 'light');
el.classList.add(theme);
el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false');
},
// === 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();
}
});