/** * 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: '', // Ereignistypen der Aktivitaets-Timeline: Label + Lucide-Icon _evMeta: { article_ingest: { label: 'Meldung', icon: '' }, refresh: { label: 'Aktualisierung', icon: '' }, analysis: { label: 'Analyse', icon: '' }, chat_qa: { label: 'Frage & Antwort', icon: '' }, source_change: { label: 'Quelle', icon: '' }, }, // 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'; }, // === 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) => `
${sel ? `` : ''} ${sel ? '' : ``}
`; let html = ''; groups.forEach(g => { if (!g.items.length) return; html += `
${g.label} ${g.items.length}
`; html += g.items.map(item).join(''); }); if (archived.length) { html += ``; 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. * (Das native confirm sieht nach Browser-Warnung aus und blockiert die Seite.) */ _confirmResolve: null, askConfirm({ title, body, okLabel = 'Löschen', danger = true }) { return new Promise((resolve) => { this._confirmResolve = resolve; document.getElementById('confirm-title').textContent = title; document.getElementById('confirm-body').innerHTML = body; const ok = document.getElementById('confirm-ok'); ok.textContent = okLabel; ok.className = 'btn ' + (danger ? 'btn-danger' : 'btn-primary'); document.getElementById('modal-confirm').classList.add('active'); setTimeout(() => document.getElementById('confirm-cancel').focus(), 50); }); }, _confirmClose(answer) { document.getElementById('modal-confirm').classList.remove('active'); const r = this._confirmResolve; this._confirmResolve = null; if (r) r(answer); }, /* === Startpanel: der frisch angelegte Fall fragt aktiv nach === Früher stand der Nutzer nach dem Anlegen vor einer leeren Oberflaeche und musste die Pipeline kennen, um irgendetwas auszuloesen. Jetzt legt das Studio die Wege offen hin - "später entscheiden" ist einer davon, aber eine bewusste Wahl. Ein Fall, für den schon einmal gewählt wurde, fragt nicht erneut. */ _renderStartPanel() { const panel = document.getElementById('start-panel'); if (!panel) return; const inc = this.incident; const leer = inc && !(this.articles || []).length && !((inc.summary || '').trim()); const schonGewaehlt = inc && localStorage.getItem('studio_start_' + inc.id); const zeigen = !!leer && !this._runningStage && !schonGewaehlt; panel.hidden = !zeigen; // "Kompletter Lauf" rechts und die Startfrage sind dieselbe Aktion - nie beide // gleichzeitig zeigen. Wird die Frage nicht (mehr) gestellt, ist der Knopf wieder // da; sonst stünde man nach einem ergebnislosen Lauf ohne Startmoeglichkeit da. const runAll = document.querySelector('.run-all'); if (runAll) runAll.hidden = zeigen; if (!zeigen) return; // Texte an den Lage-Typ anpassen: eine Recherche läuft beim ersten Mal // mit drei Durchläufen und dauert entsprechend laenger. const research = inc.type === 'research'; const full = document.getElementById('sp-full-desc'); if (full) { full.textContent = research ? 'Tiefenrecherche in drei Durchläufen (Breite Erfassung, Vertiefung, Konsolidierung), danach Analyse und Faktencheck. Dauert deutlich länger als ein einzelner Lauf.' : 'Sammeln, Analyse, Faktencheck und Karte nacheinander. Am Ende steht ein fertiges Lagebild.'; } const auto = document.getElementById('sp-auto-desc'); if (auto) { auto.textContent = inc.refresh_mode === 'auto' ? 'Ist für diesen Fall bereits eingeschaltet - der erste Lauf startet von selbst innerhalb einer Minute.' : `Der Fall läuft ab jetzt selbstständig (alle ${inc.refresh_interval || 15} Minuten). Der erste Lauf beginnt sofort.`; } this._checkSearchStatus(); }, // Ehrlich ansagen, wenn ein Lauf gerade kaum etwas bringen kann async _checkSearchStatus() { const box = document.getElementById('sp-warn'); const txt = document.getElementById('sp-warn-text'); if (!box) return; let st = null; try { st = await API.getSearchStatus(); } catch (e) { return; } const blind = !st || !st.reachable || !st.search_enabled; box.hidden = !blind; if (blind && txt) { txt.textContent = (!st || !st.reachable) ? 'Die Websuche antwortet gerade nicht (Suchdienst nicht erreichbar). Ein Lauf bringt derzeit nur Treffer aus den RSS-Feeds.' : 'Die Websuche ist abgeschaltet, weil kein mobiler Ausgang steht. Ein Lauf bringt derzeit nur Treffer aus den RSS-Feeds.'; } }, async startChoice(kind) { const panel = document.getElementById('start-panel'); const inc = this.incident; if (!inc) return; // Eine Wahl -> dieser Fall fragt nicht noch einmal, und der Startknopf rechts // kommt zurück (beide sind dieselbe Aktion). localStorage.setItem('studio_start_' + inc.id, kind); panel.hidden = true; const runAll = document.querySelector('.run-all'); if (runAll) runAll.hidden = false; if (kind === 'ingest') { this.leftTab('sources'); const ip = document.getElementById('ingest-panel'); if (ip && ip.hidden) this.toggleIngest(); document.getElementById('ingest-add-btn').focus(); return; } if (kind === 'auto') { try { await API.updateIncident(inc.id, { refresh_mode: 'auto' }); inc.refresh_mode = 'auto'; await this.loadIncidents(); UI.showToast('Automatische Aktualisierung eingeschaltet - der erste Lauf startet gleich.', 'success'); } catch (e) { UI.showToast((e && e.message) || 'Umstellen fehlgeschlagen', 'error'); localStorage.removeItem('studio_start_' + inc.id); panel.hidden = false; } return; } // 'full' (kompletter Lauf) oder 'collect' (nur sammeln) this.runStage(kind); }, // === Auswahlmodus + Massenaktionen === // Die Haken sind normalerweise WEG. Erst der Knopf "Archivieren" bzw. "Löschen" // unten schaltet den Auswahlmodus ein und blendet sie ein. startCaseMode(mode) { this.caseMode = mode; this.caseSel.clear(); this.renderCases(); }, endCaseMode() { this.caseMode = null; this.caseSel.clear(); const sa = document.getElementById('case-select-all'); if (sa) sa.checked = false; this.renderCases(); }, toggleCaseSel(id, on) { if (on) this.caseSel.add(id); else this.caseSel.delete(id); this.renderCases(); }, selectAllCases(on) { const vis = this._visibleCases(); if (on) vis.forEach(i => this.caseSel.add(i.id)); else vis.forEach(i => this.caseSel.delete(i.id)); this.renderCases(); }, _renderBulkBar() { const mode = this.caseMode; const n = this.caseSel.size; // Standardleiste (zwei Startknoepfe) vs. Auswahlmodus (bestätigen/abbrechen) const tools = document.getElementById('case-tools'); const bar = document.getElementById('bulk-bar'); const row = document.getElementById('case-select-row'); if (tools) tools.hidden = mode !== null; if (bar) bar.hidden = mode === null; if (row) row.hidden = mode === null; if (mode === null) return; const cnt = document.getElementById('bulk-count'); if (cnt) { cnt.textContent = n === 0 ? (mode === 'delete' ? 'Fälle zum Löschen anhaken' : 'Fälle zum Archivieren anhaken') : (n === 1 ? '1 Fall ausgewählt' : `${n} Fälle ausgewählt`); } // Nur anbieten, was zum Modus UND zur Auswahl passt const sel = this.incidents.filter(i => this.caseSel.has(i.id)); const anyActive = sel.some(i => i.status === 'active'); const anyArchived = sel.some(i => i.status !== 'active'); const ba = document.getElementById('bulk-archive'); const bv = document.getElementById('bulk-activate'); const bd = document.getElementById('bulk-delete'); if (ba) { ba.hidden = mode !== 'archive' || !anyActive; ba.textContent = `Archivieren (${sel.filter(i => i.status === 'active').length})`; } if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; bv.textContent = `Aktivieren (${sel.filter(i => i.status !== 'active').length})`; } if (bd) { bd.hidden = mode !== 'delete' || n === 0; bd.textContent = `Löschen (${n})`; } // "Alle"-Haken spiegelt, ob die sichtbare Menge komplett gewählt ist const vis = this._visibleCases(); const sa = document.getElementById('case-select-all'); if (sa) sa.checked = vis.length > 0 && vis.every(i => this.caseSel.has(i.id)); const hint = document.getElementById('case-sel-hint'); if (hint) hint.textContent = n ? `${n} ausgewählt` : ''; }, // Massenaktionen laufen sequentiell über die normalen Endpunkte (mit deren // Rechte-Pruefung). Fehlschlaege werden gezählt statt still verschluckt. async _bulkRun(ids, fn, verb) { let ok = 0; const failed = []; for (const id of ids) { try { await fn(id); ok++; } catch (e) { failed.push(id); } } this.caseSel.clear(); this.caseMode = null; // Auswahlmodus verlassen -> Haken verschwinden wieder await this.loadIncidents(); if (failed.length) { UI.showToast(`${ok} ${verb}, ${failed.length} fehlgeschlagen (IDs ${failed.join(', ')})`, 'error'); } else { UI.showToast(`${ok} ${ok === 1 ? 'Fall' : 'Fälle'} ${verb}`, 'success'); } }, async bulkStatus(status) { const ids = this.incidents .filter(i => this.caseSel.has(i.id) && (status === 'archived' ? i.status === 'active' : i.status !== 'active')) .map(i => i.id); if (!ids.length) return; const verb = status === 'archived' ? 'archiviert' : 'wieder aktiviert'; await this._bulkRun(ids, (id) => API.updateIncident(id, { status }), verb); }, async bulkDelete() { const sel = this.incidents.filter(i => this.caseSel.has(i.id)); if (!sel.length) return; const arts = sel.reduce((n, i) => n + (i.article_count || 0), 0); const liste = sel.slice(0, 8).map(i => `
  • ${UI.escape(i.title)}
  • `).join('') + (sel.length > 8 ? `
  • … und ${sel.length - 8} weitere
  • ` : ''); const ok = await this.askConfirm({ title: `${sel.length} ${sel.length === 1 ? 'Fall' : 'Fälle'} endgültig löschen?`, okLabel: `Löschen (${sel.length})`, body: `

    Damit gehen ${arts} Artikel samt Faktenchecks, Lageberichten und Karten verloren. Das lässt sich nicht rückgängig machen.

    Wenn du sie nur aus der Liste nehmen willst, nutze stattdessen „Archivieren“.

    `, }); 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 = ` `; 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: `

    Damit gehen ${arts} Artikel samt Faktenchecks, Lageberichten und Karten verloren. Das lässt sich nicht rückgängig machen.

    Wenn du ihn nur aus der Liste nehmen willst, nutze stattdessen „Archivieren“.

    `, }); if (!ok) return; try { await API.deleteIncident(id); if (this.incident && String(this.incident.id) === String(id)) { this.incident = null; localStorage.removeItem('studio_incident'); this._showEmpty(true); this.leftTab('cases'); document.getElementById('incident-title').textContent = ''; document.getElementById('type-badge').style.display = 'none'; } await this.loadIncidents(); UI.showToast('Fall gelöscht', 'info'); } catch (e) { UI.showToast((e && e.message) || 'Löschen fehlgeschlagen', 'error'); } }, // === Auswahl + Detail laden === async selectIncident(id) { id = parseInt(id); if (!id) return; App.currentIncidentId = id; localStorage.setItem('studio_incident', id); this._showEmpty(false); this.selected = null; this.convId = null; const si = document.getElementById('src-search-input'); if (si) si.value = ''; // Fall gewählt -> es geht mit den Quellen weiter this.leftTab('sources'); await this.loadDetail(id); this.renderCases(); // aktiven Fall in der Liste hervorheben }, async loadDetail(id) { let inc, arts, fcs, locs, srcMeta, citeSrc, fresh; try { [inc, arts, fcs, locs, srcMeta, citeSrc, fresh] = await Promise.all([ API.getIncident(id), API.getArticles(id, { limit: 500 }).catch(() => ({ articles: [] })), API.getFactChecks(id).catch(() => []), API.getLocations(id).catch(() => []), this.sourcesMeta.length ? Promise.resolve(this.sourcesMeta) : API.listSources().catch(() => []), API.getIncidentSources(id).catch(() => ({ sources: [] })), API.getFreshness(id).catch(() => null), ]); } catch (e) { UI.showToast('Lage-Details konnten nicht geladen werden', 'error'); return; } this.fresh = fresh; // Datenstand je Artefakt (Grundlage der Veraltet-Anzeige) this.incident = inc; this.articles = Array.isArray(arts) ? arts : (arts.articles || []); this.sourcesMeta = Array.isArray(srcMeta) ? srcMeta : []; // /locations antwortet mit {locations, category_labels}, nicht mit einem Array. // Ungeprüft weitergereicht wirft UI.renderMap (locations.reduce) und bricht // loadDetail ab. Gleiche Normalisierung wie im Dashboard (app.js). const locList = Array.isArray(locs) ? locs : ((locs && locs.locations) || []); const catLabels = (locs && !Array.isArray(locs) && locs.category_labels) || null; // Zitat-Quellen (getIncident liefert sources_json bewusst nicht mit) this.citeSources = (citeSrc && citeSrc.sources) || []; this.renderHeader(inc); this.renderSources(); // Quellen-Ingest: Zustand je Fall zurücksetzen + laufende/fertige Uploads laden this._stopUploadPoll(); this._seenDone = new Set(); this.uploads = []; const ip = document.getElementById('ingest-panel'); if (ip) ip.hidden = true; this.loadUploads(); this.renderArtifacts(inc, fcs, locList, catLabels); this._stagesIdle(); // Karten freischalten + Datenstand anzeigen this._resumeStage(); // läuft gerade ein Baustein? -> Karte zurück auf "läuft" this._renderStartPanel(); // leerer Fall? -> aktiv nach dem nächsten Schritt fragen this.renderChatIntro(inc); // Snapshots lazy nachladen this._snapLoaded = false; // Studio-Tabs zurücksetzen und Lagebild als Standard-Tab öffnen this._initTabs(); }, _sources(inc) { // Zitat-Quellen aus /incidents/{id}/sources (getIncident liefert sie nicht mit) return this.citeSources || []; }, _isResearch() { return this.incident && this.incident.type === 'research'; }, renderHeader(inc) { const badge = document.getElementById('type-badge'); const research = inc.type === 'research'; // Der Titel stand früher im Dropdown - ohne ihn wüsste man nicht, worin man arbeitet const t = document.getElementById('incident-title'); if (t) { t.textContent = inc.title || ''; t.title = inc.title || ''; } badge.textContent = research ? 'Analyse' : 'Live'; badge.className = 'studio-type-badge' + (research ? ' type-research' : ''); badge.style.display = ''; document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild'; document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen'; }, // === Spalte 1: Quellen === _norm(s) { return (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim(); }, _domainOf(url) { try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); } catch (e) { return ''; } }, _reg(dom) { return (dom || '').split('.').slice(-2).join('.'); }, _feedFor(sourceName, sampleUrl) { // 1. Domain-Match (zuverlässig) — exakter Host, dann registrierbare Domain. // KEIN Fuzzy-Namensmatch: "Bundestag" würde sonst fälschlich die // Telegram-Quelle "AfD-Fraktion im Bundestag" (niedrig) treffen. const dom = this._domainOf(sampleUrl); if (dom) { let f = this.sourcesMeta.find(s => s.domain && s.domain.toLowerCase() === dom); if (f) return f; const reg = this._reg(dom); f = this.sourcesMeta.find(s => s.domain && this._reg(s.domain.toLowerCase()) === reg); if (f) return f; } // 2. Exakter (normalisierter) Namensmatch als Fallback — kein Teilstring. const n = this._norm(sourceName); if (!n) return null; return this.sourcesMeta.find(s => this._norm(s.name) === n) || null; }, renderSources() { const list = document.getElementById('sources-list'); // Fertige Uploads je Artikel -> Kategorie + Aktionen const upByArt = {}; (this.uploads || []).forEach(u => { if (u.status === 'done' && u.article_id) upByArt[u.article_id] = u; }); // Artikel nach Quelle gruppieren; Quelle einer Kategorie zuordnen // (reguläre/RSS-Quellen -> "webseiten", hinzugefügte Materialien -> ihre Kategorie) const groups = {}; this.articles.forEach(a => { const name = a.source || 'Unbekannt'; const up = upByArt[a.id]; if (!groups[name]) groups[name] = { name, arts: [], langs: new Set(), cat: 'webseiten' }; groups[name].arts.push(a); groups[name].langs.add((a.language || 'de').toUpperCase()); if (up) groups[name].cat = up.category || 'dokumente'; }); const arr = Object.values(groups); // Im Reiter ist nur Platz für die Zahl; das Ausführliche steckt im Tooltip const sc = document.getElementById('sources-count'); sc.textContent = arr.length || ''; sc.title = `${this.articles.length} Artikel aus ${arr.length} Quellen`; if (!arr.length) { list.innerHTML = '
    Noch keine Artikel. Starte eine Aktualisierung.
    '; 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 += `
    ${meta.icon}${meta.label}${nArts}
    `; html += gs.map(g => this._renderSourceGroup(g, upByArt)).join(''); html += `
    `; }); 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 `
    ${h}
    ${this._uploadActions(up)}
    `; } return a.source_url ? `${h}` : `
    ${h}
    `; }).join(''); return `
    ${nameEsc} ${badges} ${[...g.langs].join('/')} ${g.arts.length}
    ${arts}
    `; }, 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: '' }, dokumente: { label: 'Dokumente', icon: '' }, bilder: { label: 'Bilder', icon: '' }, sprachnachrichten:{ label: 'Sprachnachrichten', icon: '' }, }, 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 += `
    ${meta.icon}${meta.label}${groups[cat].length}
    `; groups[cat].forEach(u => { html += this._renderUploadItem(u); }); html += `
    `; }); 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 = `
    ${UI.escape(u.stage || 'In Arbeit')} · ${pct}%
    `; } else if (u.status === 'error') { status = `
    Fehler: ${UI.escape(u.error || 'unbekannt')}
    `; } else if (u.status === 'done') { status = `
    ${this._uploadActions(u)}
    `; } return `
    ${name}
    ${status}
    `; }, _uploadActions(u) { const aid = u.article_id || 0; const btns = []; if (u.category === 'sprachnachrichten') { btns.push(``); btns.push(``); } else if (u.category === 'bilder') { btns.push(``); btns.push(``); } else if (u.category === 'webseiten') { btns.push(``); if (u.source_url) btns.push(`Link öffnen`); } else { btns.push(``); if (u.has_file) btns.push(``); } 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 = `
    ${txt ? UI.escape(txt).slice(0, 8000).replace(/\n/g, '
    ') : 'Text wird vorbereitet …'}
    `; 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 = 'Lädt …'; host.dataset.open = 'audio'; try { const url = await API.fetchUploadBlobUrl(this.incident.id, uid); host.innerHTML = ``; } catch (e) { host.innerHTML = `${UI.escape(e.message || 'Audio-Fehler')}`; } }, 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: '

    Die hochgeladene Datei und der daraus erzeugte Text werden entfernt.

    ', }); 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) : 'Keine separate Zusammenfassung.'; } 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 ? `
    ${filters}
    ` : '') + fcs.map(fc => UI.renderFactCheck(fc)).join(''); } else { document.getElementById('art-fc-meta').textContent = ''; fcBody.innerHTML = 'Noch keine Faktenchecks.'; } // Faktencheck-Historie (frühere, komplett-neu ersetzte Läufe) fcBody.insertAdjacentHTML('beforeend', `
    Frühere Faktencheck-Läufe
    Beim Öffnen geladen.
    `); // 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 = 'Wird beim Öffnen geladen.'; // Snapshots-Meta zurücksetzen document.getElementById('art-snap-meta').textContent = ''; document.getElementById('art-snap-body').innerHTML = 'Wird beim Öffnen geladen.'; }, // 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 = 'Lade Ereignisse …'; let events = []; try { const res = await API.getEvents(this.incident.id); events = (res && res.events) || []; } catch (e) { el.innerHTML = 'Ereignisse konnten nicht geladen werden.'; return; } document.getElementById('art-tl-meta').textContent = events.length + ' Ereignisse'; if (!events.length) { el.innerHTML = 'Noch keine Ereignisse.'; 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 `
    ${icon} ${UI.escape(s.label)} ${groups[s.key].length}
    ${items}
    `; }).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 = `
    ${UI.escape(q)}
    ` + (a ? `
    Antwort anzeigen
    ${this._renderReply(a, [])}
    ` : ''); } else if (ev.type === 'article_ingest') { const t = ev.url ? `${UI.escape(ev.title || '')}` : UI.escape(ev.title || ''); body = `
    ${t}
    ` + (ev.source ? `
    ${UI.escape(ev.source)}
    ` : ''); } else { body = `
    ${UI.escape(ev.title || '')}
    ` + (ev.source ? `
    ${UI.escape(ev.source)}
    ` : ''); } return `
    ${time}
    ${body}
    `; }, 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 = 'Noch keine früheren Lageberichte.'; 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 `
    ${t}${meta}
    ${prev}${prev ? ' …' : ''}
    `; }).join(''); } catch (e) { body.innerHTML = 'Konnte Lageberichte nicht laden.'; } }, async onSnapToggle(el, snapId) { if (!el.open || el.dataset.loaded) return; el.dataset.loaded = '1'; const bodyEl = el.querySelector('.hist-body'); bodyEl.innerHTML = 'Lädt …'; 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 = 'Konnte Bericht nicht laden.'; } }, 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 = 'Noch keine früheren Läufe.'; return; } body.innerHTML = runs.map(r => { const t = r.created_at ? this._fmt(parseUTC(r.created_at)) : ''; return `
    ${t}${r.fact_count || 0} Fakten
    Lädt …
    `; }).join(''); } catch (e) { body.innerHTML = 'Konnte Historie nicht laden.'; } }, 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('') : 'Keine Fakten in diesem Lauf.'; } catch (e) { body.innerHTML = 'Konnte Lauf nicht laden.'; } }, // === Verschiebbare Trennung Tab-Panel <-> Konversation === _initDivider() { const divider = document.getElementById('center-divider'); const center = document.getElementById('studio-center'); if (!divider || !center) return; const chat = center.querySelector('.chat-region'); // gespeicherte Höhe wiederherstellen const saved = parseInt(localStorage.getItem('studio_chat_h') || '', 10); if (saved) center.style.setProperty('--chat-h', saved + 'px'); let startY = 0, startH = 0; const onMove = (e) => { const dy = e.clientY - startY; let h = startH - dy; // nach oben ziehen => Chat größer const minChat = 140; const maxChat = center.clientHeight - 180; // Tab-Panel behält Mindesthoehe h = Math.max(minChat, Math.min(Math.max(minChat, maxChat), h)); center.style.setProperty('--chat-h', h + 'px'); }; const onUp = (e) => { divider.classList.remove('dragging'); document.body.style.userSelect = ''; document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); try { divider.releasePointerCapture(e.pointerId); } catch (_) {} // Karte neu vermessen, falls Karten-Tab aktiv if (this.activeTab === 'map' && UI.invalidateMap) UI.invalidateMap(); const cur = center.style.getPropertyValue('--chat-h'); if (cur.endsWith('px')) localStorage.setItem('studio_chat_h', parseInt(cur, 10)); }; divider.addEventListener('pointerdown', (e) => { startY = e.clientY; startH = chat.getBoundingClientRect().height; divider.classList.add('dragging'); document.body.style.userSelect = 'none'; try { divider.setPointerCapture(e.pointerId); } catch (_) {} document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); e.preventDefault(); }); }, // Tabs per Drag&Drop umsortieren (Delegation auf der statischen Tabbar) _initTabDnD() { const bar = document.getElementById('center-tabbar'); if (!bar) return; const clearMarkers = () => bar.querySelectorAll('.center-tab').forEach(t => t.classList.remove('drop-before', 'drop-after')); bar.addEventListener('dragstart', (e) => { const tab = e.target.closest('.center-tab'); if (!tab) return; this._dragKey = tab.dataset.art; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', tab.dataset.art); } catch (_) {} tab.classList.add('dragging-tab'); }); bar.addEventListener('dragend', (e) => { const tab = e.target.closest('.center-tab'); if (tab) tab.classList.remove('dragging-tab'); this._dragKey = null; clearMarkers(); }); bar.addEventListener('dragover', (e) => { if (!this._dragKey) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearMarkers(); const over = e.target.closest('.center-tab'); if (over && over.dataset.art !== this._dragKey) { const r = over.getBoundingClientRect(); const after = (e.clientX - r.left) > r.width / 2; over.classList.add(after ? 'drop-after' : 'drop-before'); } }); bar.addEventListener('drop', (e) => { if (!this._dragKey) return; e.preventDefault(); const fromKey = this._dragKey; const over = e.target.closest('.center-tab'); let toIndex; if (over && over.dataset.art !== fromKey) { const r = over.getBoundingClientRect(); const after = (e.clientX - r.left) > r.width / 2; const overIdx = this.openTabs.indexOf(over.dataset.art); toIndex = after ? overIdx + 1 : overIdx; } else { toIndex = this.openTabs.length; } const fromIdx = this.openTabs.indexOf(fromKey); if (fromIdx === -1) return; this.openTabs.splice(fromIdx, 1); if (fromIdx < toIndex) toIndex--; this.openTabs.splice(toIndex, 0, fromKey); this._dragKey = null; clearMarkers(); this._renderTabbar(); // baut Tabs neu, aktiver Zustand bleibt erhalten }); }, // === Studio-Tabs in der Mitte === _tabTitle(key) { const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`); return el ? el.textContent.trim() : key; }, _tabIcon(key) { const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-icon`); return el ? el.innerHTML : ''; }, _initTabs() { this.openTabs = []; this.activeTab = null; document.getElementById('studio-center').classList.remove('has-tabs'); document.getElementById('center-tabbar').innerHTML = ''; document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => m.classList.remove('open', 'active')); // Standard: Lagebild/Recherchebericht als erster Tab this.openTab('summary'); }, _renderTabbar() { const bar = document.getElementById('center-tabbar'); bar.innerHTML = this.openTabs.map(key => { const k = key.replace(/'/g, "\\'"); return ``; }).join(''); document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => { m.classList.toggle('open', this.openTabs.includes(m.dataset.art)); }); }, openTab(key) { if (!this.openTabs.includes(key)) this.openTabs.push(key); document.getElementById('studio-center').classList.add('has-tabs'); this._renderTabbar(); this.activateTab(key); }, activateTab(key) { this.activeTab = key; document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => { p.classList.toggle('active', p.dataset.art === key); }); document.querySelectorAll('#center-tabbar .center-tab').forEach(t => { t.classList.toggle('active', t.dataset.art === key); }); document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => { m.classList.toggle('active', m.dataset.art === key); }); // Nebenwirkungen: Karte neu vermessen / Snapshots lazy laden if (key === 'map') { setTimeout(() => { if (UI.retryPendingMap) UI.retryPendingMap(); if (UI.invalidateMap) UI.invalidateMap(); }, 60); } if (key === 'snapshots') this.loadSnapshots(); if (key === 'timeline') this.loadTimeline(); }, closeTab(key, ev) { if (ev) ev.stopPropagation(); const idx = this.openTabs.indexOf(key); if (idx === -1) return; this.openTabs.splice(idx, 1); const mi = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"]`); if (mi) mi.classList.remove('open', 'active'); if (!this.openTabs.length) { this.activeTab = null; document.getElementById('studio-center').classList.remove('has-tabs'); this._renderTabbar(); document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => p.classList.remove('active')); return; } // Nachbar-Tab aktivieren, wenn der aktive geschlossen wurde let nextKey = this.activeTab; if (this.activeTab === key || !this.openTabs.includes(this.activeTab)) { nextKey = this.openTabs[Math.min(idx, this.openTabs.length - 1)]; } this._renderTabbar(); this.activateTab(nextKey); }, // === Spalte 2: RAG-Chat === renderChatIntro(inc) { const box = document.getElementById('chat-messages'); const sources = this._sources(inc); let intro = ''; if (inc.summary) { const ex = inc.type === 'research' ? UI.extractZusammenfassung(inc.summary) : { zusammenfassung: null }; const short = ex.zusammenfassung ? UI.renderZusammenfassung(ex.zusammenfassung, sources) : UI.renderSummary(inc.summary, sources, inc.type); intro = `
    ${inc.type === 'research' ? 'Recherchebericht' : 'Lagebild'}
    ${short}
    `; } else { intro = `
    Zu diesem Fall gibt es noch kein Lagebild. Starte eine Aktualisierung.
    `; } 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', `
    ${UI.escape(msg)}
    `); 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 ? 'Der Lage-Chat ist noch nicht aktiviert (Backend-Neustart erforderlich).' : '' + UI.escape((e && e.message) || 'Fehler bei der Anfrage.') + ''; } 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, '$1'); html = html.replace(/\n/g, '
    '); 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 `[${n}]`; }); 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 `
    Dazu liegt in diesem Fall noch nichts vor.
    Ich kann die Fallbeschreibung um diesen Aspekt ergänzen und gezielt dazu recherchieren, ausschließlich zu dieser Frage.
    Suchfokus: ${focus}
    `; }, 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 = `
    Gezielte Recherche abgeschlossen.
    ${n} neue Meldung${n === 1 ? '' : 'en'} zur Frage erfasst. Starte Analyse oder Faktencheck neu und stelle die Frage erneut.
    `; this._tlLoaded = false; try { this.fresh = await API.getFreshness(incId); this._renderFreshness(); } catch (e) {} } else if (st && st.status === 'error') { if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; } if (textEl) textEl.textContent = 'Die Recherche ist fehlgeschlagen. Bitte erneut versuchen.'; } else { setTimeout(tick, 4000); } }; setTimeout(tick, 3000); }, // === Bausteine (Kacheln in Spalte 3) === // Ein Klick startet den Baustein sofort. Läuft er, zeigt die Kachel Spinner + // Fortschritt; bei den Orchestrator-Bausteinen (Sammeln, Kompletter Lauf) bricht // ein zweiter Klick ab. Analyse/Faktencheck/Geoparsing haben backendseitig keinen // Abbruch - ihre Kachel ist während des Laufs gesperrt statt einen toten Knopf zu zeigen. _CANCELLABLE: ['collect', 'full'], _STAGE_LABELS: { collect: 'Sammeln', analyze: 'Analyse', factcheck: 'Faktencheck', geoparse: 'Geoparsing', network: 'Netzwerkanalyse', full: 'Kompletter Lauf', }, // "Aktualisieren" von außen (Tastatur/WS-Resync) = kompletter Lauf async refresh() { return this.runStage('full'); }, // Alle Start-Elemente tragen data-stage: die ↻-Knoepfe in den Karten, "Kompletter // Lauf" und der Sammeln-Knopf in der Quellenspalte. _stageEls() { return Array.from(document.querySelectorAll('[data-stage]')); }, _stageEl(stage) { return document.querySelector(`[data-stage="${CSS.escape(stage)}"]`); }, _stagesIdle() { this._runningStage = null; // Ohne Artikel können Analyse, Faktencheck und Geoparsing nicht laufen // (das Backend wirft "Keine Artikel vorhanden") - erst gar nicht anbieten. const ohneArtikel = !(this.articles || []).length; const brauchtArtikel = ['analyze', 'factcheck', 'geoparse']; this._stageEls().forEach(b => { b.classList.remove('running'); const s = b.dataset.stage; // Netzwerkanalyse bleibt gesperrt (Baustein folgt im Backend) b.disabled = (s === 'network') || !this.incident || (ohneArtikel && brauchtArtikel.includes(s)); }); document.querySelectorAll('.art-card').forEach(c => c.classList.remove('busy')); const ra = document.querySelector('.run-all .ra-state'); if (ra) ra.textContent = ''; this._renderFreshness(); }, _stageRunning(stage, text) { if (!stage) return; this._runningStage = stage; // Sobald etwas läuft, ist die Startfrage beantwortet - auch wenn der Lauf // über den Sammeln-Knopf links statt über das Panel ausgelöst wurde. const panel = document.getElementById('start-panel'); if (panel) panel.hidden = true; if (this.incident) localStorage.setItem('studio_start_' + this.incident.id, 'run'); const cancellable = this._CANCELLABLE.includes(stage); this._stageEls().forEach(b => { const isThis = b.dataset.stage === stage; b.classList.toggle('running', isThis); // Die laufende Aktion bleibt nur klickbar, wenn sie abbrechbar ist b.disabled = isThis ? !cancellable : true; }); document.querySelectorAll('.art-card').forEach(c => { c.classList.toggle('busy', c.dataset.card === stage); }); const ra = document.querySelector('.run-all .ra-state'); if (ra) ra.textContent = stage === 'full' ? (text || 'läuft …') + ' · Abbrechen' : ''; this._renderFreshness(text); }, // App-Zeitstempel stehen bereits in lokaler Zeit -> nur umformatieren, NICHT als UTC lesen _fmtStamp(ts) { const m = String(ts || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/); return m ? `${m[3]}.${m[2]}. ${m[4]}:${m[5]}` : ''; }, _setNote(card, cls, html) { const el = document.querySelector(`.art-note[data-note="${card}"]`); if (!el) return; el.className = 'art-note show ' + cls; el.innerHTML = html; }, /** * Datenstand je Karte anzeigen. Die Karte meldet selbst, ob ihr Ergebnis noch * zur Datenlage passt - so muss niemand die Pipeline im Kopf haben. */ _renderFreshness(runText) { const f = this.fresh; const run = this._runningStage; const spin = ''; const btn = (stage, label) => ``; // Sammeln-Knopf spiegelt den Lauf mit const cb = document.getElementById('collect-btn'); if (cb) cb.classList.toggle('running', run === 'collect'); const running = (card, label) => this._setNote(card, 'running', `${spin} ${label} läuft …`); const waiting = (card) => this._setNote(card, 'blocked', `wartet, bis ${this._STAGE_LABELS[run] || 'der Baustein'} fertig ist`); // Ohne Freshness-Daten (alte Lage frisch geladen / Endpunkt weg): Karten neutral lassen const cards = ['analyze', 'factcheck', 'geoparse']; if (run) { cards.forEach(c => (run === c ? running(c, this._STAGE_LABELS[c]) : waiting(c))); if (run === 'collect' || run === 'full') { cards.forEach(c => waiting(c)); } } this._setNote('network', 'blocked', 'Baustein folgt'); this._setNote('timeline', 'fresh', 'entsteht automatisch aus den Artikeln'); if (run || !f) return; const arts = f.articles || 0; // --- Lagebild (Analyse) --- const s = f.summary || {}; const sumMeta = document.getElementById('art-summary-meta'); if (sumMeta) { sumMeta.textContent = s.exists ? `${arts} Artikel ausgewertet · ${this._fmtStamp(s.last)}` : 'noch nicht erzeugt'; } if (!arts) { this._setNote('analyze', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht'); } else if (!s.exists) { this._setNote('analyze', 'blocked', 'noch nicht erzeugt' + btn('analyze', 'Erzeugen')); } else if (s.pending > 0) { this._setNote('analyze', 'stale', `${s.pending} neue Artikel seit dem letzten Bericht` + btn('analyze', 'Aktualisieren')); } else { this._setNote('analyze', 'fresh', 'aktuell'); } // --- Faktencheck --- const fc = f.factcheck || {}; const fcMeta = document.getElementById('art-fc-meta'); if (fcMeta) { fcMeta.textContent = fc.exists ? `${fc.facts} Fakten · ${this._fmtStamp(fc.last)}` : 'noch nicht erzeugt'; } if (!arts) { this._setNote('factcheck', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht'); } else if (!fc.exists) { this._setNote('factcheck', 'blocked', 'noch nicht erzeugt' + btn('factcheck', 'Erzeugen')); } else if (fc.pending > 0) { this._setNote('factcheck', 'stale', `${fc.pending} neue Artikel seit dem letzten Faktencheck` + btn('factcheck', 'Aktualisieren')); } else { this._setNote('factcheck', 'fresh', 'aktuell'); } // --- Karte (Geoparsing) --- offen ist hier nicht "neu seit", sondern "noch nicht geprüft" const geo = f.geoparse || {}; if (!arts) { this._setNote('geoparse', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht'); } else if (geo.pending > 0) { this._setNote('geoparse', 'stale', `${geo.pending} Artikel noch nicht verortet` + btn('geoparse', 'Verorten')); } else { this._setNote('geoparse', 'fresh', 'alle Artikel verortet'); } // --- Nebenangaben --- const snapMeta = document.getElementById('art-snap-meta'); if (snapMeta && !snapMeta.textContent) snapMeta.textContent = f.snapshots ? String(f.snapshots) : ''; const tlMeta = document.getElementById('art-tl-meta'); if (tlMeta && !tlMeta.textContent && f.events) tlMeta.textContent = `${f.events} Ereignisse`; }, async runStage(stage) { if (!this.incident) return; // Läuft genau dieser Baustein schon? -> Klick bedeutet Abbrechen if (this._runningStage === stage && this._CANCELLABLE.includes(stage)) { try { await API.cancelRefresh(this.incident.id); UI.showToast('Abbruch angefordert …', 'info'); } catch (e) { UI.showToast((e && e.message) || 'Abbruch fehlgeschlagen', 'error'); } return; } if (this._runningStage) { UI.showToast('Es läuft bereits ein Baustein für diese Lage.', 'info'); return; } const label = this._STAGE_LABELS[stage] || 'Baustein'; this._stageRunning(stage, 'startet …'); try { if (stage === 'full') { // Beim leeren Fall ist es eine Recherche, sonst eine Aktualisierung - // dieselbe Aktion, aber der Nutzer soll den Namen wiedererkennen, den // er angeklickt hat. const erstlauf = !(this.articles || []).length; await API.refreshIncident(this.incident.id); this._showStatus('In Warteschlange ...'); UI.showToast(erstlauf ? 'Recherche gestartet' : 'Kompletter Lauf gestartet', 'info'); } else if (stage === 'geoparse') { await API.triggerGeoparse(this.incident.id); this._showStatus('Geoparsing läuft …'); this._pollGeoparse(); } else { await API.runStage(this.incident.id, stage); this._showStatus(label + ' läuft …'); // 'collect' läuft über die Refresh-Pipeline -> Abschluss/Reload via WebSocket // (_wireWs: refresh_complete/refresh_error). Analyse/Faktencheck via Polling. if (stage !== 'collect') this._pollStage(); } this._stageRunning(stage, 'läuft …'); } catch (e) { this._stagesIdle(); this._hideStatus(); const busy = e && e.status === 409; UI.showToast(busy ? 'Es läuft bereits ein Baustein für diese Lage.' : ((e && e.message) || 'Start fehlgeschlagen'), 'error'); } }, _pollStage() { if (this._stagePoll) return; this._stagePoll = setInterval(async () => { let res; try { res = await API.getRunStatus(this.incident.id); } catch (e) { return; } const st = res && res.state; if (!st) { this._stopStagePoll(); this._hideStatus(); this._stagesIdle(); return; } if (st.status === 'running') { this._showStatus((st.label || 'Baustein') + ' läuft …'); this._stageRunning(st.stage || this._runningStage, 'läuft …'); } else { this._stopStagePoll(); this._hideStatus(); this._stagesIdle(); if (st.status === 'done') { UI.showToast((st.label || 'Baustein') + ' fertig', 'info'); this.softRefresh(); } else if (st.status === 'error') { UI.showToast((st.label || 'Baustein') + ' fehlgeschlagen: ' + (st.error || ''), 'error'); } } }, 2000); }, _stopStagePoll() { if (this._stagePoll) { clearInterval(this._stagePoll); this._stagePoll = null; } }, _pollGeoparse() { if (this._geoPoll) return; this._geoPoll = setInterval(async () => { let st; try { st = await API.getGeoparseStatus(this.incident.id); } catch (e) { return; } if (st && st.status === 'done') { this._stopGeoPoll(); this._hideStatus(); this._stagesIdle(); UI.showToast('Geoparsing fertig · ' + (st.locations || 0) + ' Orte', 'info'); this.softRefresh(); } else if (st && st.status === 'error') { this._stopGeoPoll(); this._hideStatus(); this._stagesIdle(); UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error'); } else { // running / idle if (st && st.total) { const prog = (st.processed || 0) + '/' + st.total; this._showStatus('Geoparsing läuft … ' + prog); this._stageRunning('geoparse', prog); } } }, 2000); }, _stopGeoPoll() { if (this._geoPoll) { clearInterval(this._geoPoll); this._geoPoll = null; } }, async _resumeStage() { // Beim Öffnen einer Lage: läuft dort gerade ein Baustein? -> Kachel + Polling aufnehmen this._stopStagePoll(); this._stopGeoPoll(); try { const res = await API.getRunStatus(this.incident.id); if (res && res.state && res.state.status === 'running') { this._showStatus((res.state.label || 'Baustein') + ' läuft …'); this._stageRunning(res.state.stage, 'läuft …'); this._pollStage(); return; } } catch (e) { /* ignore */ } try { const g = await API.getGeoparseStatus(this.incident.id); if (g && g.status === 'running') { this._showStatus('Geoparsing läuft …'); this._stageRunning('geoparse', 'läuft …'); this._pollGeoparse(); } } catch (e) { /* ignore */ } }, // Soft-Refresh (F5): Daten neu laden statt die Seite neu zu laden (wie im Monitor) async softRefresh() { await this.loadIncidents(); if (!this.incident) return; const openBefore = this.openTabs.slice(); const activeBefore = this.activeTab; await this.loadDetail(this.incident.id); // geöffnete Tabs wiederherstellen (loadDetail öffnet sonst nur das Lagebild) if (openBefore.length) { this.openTabs = []; document.getElementById('center-tabbar').innerHTML = ''; openBefore.forEach(k => this.openTab(k)); if (activeBefore && this.openTabs.includes(activeBefore)) this.activateTab(activeBefore); } UI.showToast('Aktualisiert', 'info'); }, _showStatus(text) { const bar = document.getElementById('live-status'); document.getElementById('live-status-text').textContent = text; bar.classList.add('active'); if (!this._startTime) this._startTime = Date.now(); if (!this._timerInt) { this._timerInt = setInterval(() => { const s = Math.floor((Date.now() - this._startTime) / 1000); document.getElementById('live-status-timer').textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }, 1000); } }, _hideStatus() { document.getElementById('live-status').classList.remove('active'); if (this._timerInt) { clearInterval(this._timerInt); this._timerInt = null; } this._startTime = null; document.getElementById('live-status-timer').textContent = ''; }, _wireWs() { const forCurrent = (msg) => this.incident && String(msg.incident_id) === String(this.incident.id); WS.on('status_update', (msg) => { if (!forCurrent(msg)) return; const label = (typeof UI._getStepLabel === 'function') ? UI._getStepLabel(msg.status) : (msg.status || ''); this._showStatus(msg.detail || label || 'Aktualisierung läuft ...'); }); WS.on('pipeline_step', (msg) => { if (!forCurrent(msg)) return; if (msg.label || msg.step) this._showStatus(msg.label || msg.step); }); WS.on('refresh_complete', (msg) => { if (!forCurrent(msg)) return; const wasCollect = this._runningStage === 'collect'; this._hideStatus(); this._stagesIdle(); UI.showToast(wasCollect ? 'Sammeln abgeschlossen' : 'Aktualisierung abgeschlossen', 'info'); this.loadDetail(this.incident.id); this.loadIncidents(); }); WS.on('refresh_error', (msg) => { if (!forCurrent(msg)) return; this._hideStatus(); this._stagesIdle(); UI.showToast('Aktualisierung fehlgeschlagen', 'error'); }); WS.on('refresh_cancelled', (msg) => { if (!forCurrent(msg)) return; this._hideStatus(); this._stagesIdle(); UI.showToast('Abgebrochen', 'info'); }); }, async syncRefreshStatus() { // Nach WS-Reconnect: laufende Refreshes abgleichen try { const running = await API.getRefreshingIncidents(); const ids = (running || []).map(r => String(r.incident_id || r.id)); if (this.incident && ids.includes(String(this.incident.id))) { this._showStatus('Aktualisierung läuft ...'); // Welcher der beiden Orchestrator-Bausteine läuft, sagt uns der Server nicht; // ohne bekannten Zustand als "Kompletter Lauf" anzeigen (beide sind abbrechbar). this._stageRunning(this._runningStage || 'full', 'läuft …'); } else { this._hideStatus(); if (this._runningStage === 'collect' || this._runningStage === 'full') this._stagesIdle(); } } catch (e) { /* ignore */ } }, // === Neuen Fall anlegen (gleicher Funktionsumfang wie im Dashboard) === openNewIncident() { const form = document.getElementById('new-incident-form'); form.reset(); document.getElementById('inc-visibility').checked = true; document.getElementById('btn-enhance-description').disabled = true; this.incTypeDefaults(); this.incRefreshToggle(); this.incVisibilityHint(); this._gateXToggle(); const m = document.getElementById('modal-new'); m.classList.add('active'); setTimeout(() => document.getElementById('inc-title').focus(), 50); }, closeNewIncident() { document.getElementById('modal-new').classList.remove('active'); if (this._enhanceController) { this._enhanceController.abort(); this._enhanceController = null; } }, // X-Kanäle nur freigeben, wenn ein X-Zugang hinterlegt ist (wie im Dashboard) async _gateXToggle() { const cb = document.getElementById('inc-x'); const hint = document.getElementById('inc-x-hint'); if (!cb) return; let active = 0; try { const accounts = await API.listXAccounts(); active = (accounts || []).filter(a => a.active).length; } catch (e) { active = 0; // im Zweifel sperren } cb.disabled = active === 0; if (active === 0) cb.checked = false; if (hint) hint.style.display = active === 0 ? '' : 'none'; }, incTitleChanged() { const t = document.getElementById('inc-title').value.trim(); document.getElementById('btn-enhance-description').disabled = t.length < 3; }, incTypeDefaults() { const type = document.getElementById('inc-type').value; const hint = document.getElementById('type-hint'); const mode = document.getElementById('inc-refresh-mode'); if (type === 'research') { hint.textContent = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.'; mode.value = 'manual'; } else { hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.'; } this.incRefreshToggle(); }, incRefreshToggle() { const auto = document.getElementById('inc-refresh-mode').value === 'auto'; document.getElementById('refresh-interval-field').classList.toggle('visible', auto); document.getElementById('refresh-starttime-field').classList.toggle('visible', auto); }, incIntervalMin() { const unit = parseInt(document.getElementById('inc-refresh-unit').value); const input = document.getElementById('inc-refresh-value'); const min = unit === 1 ? 10 : 1; input.min = min; if (parseInt(input.value) < min) input.value = min; }, incVisibilityHint() { const pub = document.getElementById('inc-visibility').checked; document.getElementById('visibility-text').textContent = pub ? 'Öffentlich : für alle Nutzer sichtbar' : 'Privat : nur für dich sichtbar'; }, async generateDescription() { const title = document.getElementById('inc-title').value.trim(); const description = document.getElementById('inc-description').value.trim(); const type = document.getElementById('inc-type').value; const btn = document.getElementById('btn-enhance-description'); const btnText = document.getElementById('enhance-btn-text'); const spinner = document.getElementById('enhance-spinner'); const area = document.getElementById('inc-description'); if (title.length < 3) return; if (this._enhanceController) this._enhanceController.abort(); this._enhanceController = new AbortController(); btn.disabled = true; btnText.textContent = 'Wird generiert …'; spinner.style.display = ''; area.readOnly = true; try { const res = await API.enhanceDescription(title, description || null, type, this._enhanceController.signal); area.value = res.description || ''; } catch (err) { if (err && err.name !== 'AbortError') { let msg = 'Beschreibung konnte nicht generiert werden'; if (err.status === 503) msg = 'KI-Zugang aktuell nicht verfügbar.'; else if (err.status === 429) msg = 'KI ist gerade ausgelastet. Bitte kurz warten.'; else if (err.status === 504) msg = 'KI antwortet gerade nicht. Bitte erneut versuchen.'; UI.showToast(msg, 'error'); } } finally { spinner.style.display = 'none'; btnText.textContent = 'Beschreibung generieren'; area.readOnly = false; btn.disabled = title.length < 3; this._enhanceController = null; } }, _incidentFormData() { const value = parseInt(document.getElementById('inc-refresh-value').value) || 15; const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1; const auto = document.getElementById('inc-refresh-mode').value === 'auto'; return { title: document.getElementById('inc-title').value.trim(), description: document.getElementById('inc-description').value.trim() || null, type: document.getElementById('inc-type').value, refresh_mode: document.getElementById('inc-refresh-mode').value, refresh_interval: Math.max(10, Math.min(10080, value * unit)), refresh_start_time: auto ? (document.getElementById('inc-refresh-starttime').value || null) : null, retention_days: parseInt(document.getElementById('inc-retention').value) || 0, international_sources: document.getElementById('inc-international').checked, include_telegram: document.getElementById('inc-telegram').checked, include_x: document.getElementById('inc-x').checked, visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private', }; }, async submitIncident(ev) { ev.preventDefault(); const btn = document.getElementById('modal-new-submit'); const titleEl = document.getElementById('inc-title'); if (!titleEl.value.trim()) { titleEl.focus(); return; } btn.disabled = true; try { const inc = await API.createIncident(this._incidentFormData()); // E-Mail-Abo wie im Dashboard mitspeichern try { await API.updateSubscription(inc.id, { notify_email_summary: document.getElementById('inc-notify-summary').checked, notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked, notify_email_status_change: document.getElementById('inc-notify-status-change').checked, }); } catch (e) { /* Abo ist Beiwerk - der Fall steht */ } this.closeNewIncident(); await this.loadIncidents(); await this.selectIncident(inc.id); UI.showToast('Fall angelegt', 'success'); } catch (e) { UI.showToast((e && e.message) || 'Fall konnte nicht angelegt werden', 'error'); } finally { btn.disabled = false; } }, // === Export === async doExport() { if (!this.incident) return; const format = document.getElementById('export-format').value; const sections = ['zusammenfassung', 'bericht', 'faktencheck', 'quellen']; try { const resp = await API.exportReport(this.incident.id, format, null, sections); if (!resp.ok) throw new Error('Export fehlgeschlagen'); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `Lagebericht_${this.incident.id}.${format}`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (e) { UI.showToast('Export fehlgeschlagen', 'error'); } }, // === Theme === toggleTheme() { const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; const next = cur === 'light' ? 'dark' : 'light'; if (next === 'light') document.documentElement.setAttribute('data-theme', 'light'); else document.documentElement.removeAttribute('data-theme'); localStorage.setItem('osint_theme', next); 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(); } });