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