diff --git a/src/main.py b/src/main.py index 9bec45c..58a0f45 100644 --- a/src/main.py +++ b/src/main.py @@ -490,6 +490,18 @@ async def dashboard(): return FileResponse(os.path.join(STATIC_DIR, "dashboard.html")) +@app.get("/studio") +async def studio(): + """Studio-Ansicht (experimentelle 3-Spalten-UI) ausliefern. + + Vorerst nur fuer info@aegis-sight.de gedacht. Bearer-Auth greift bei einer + Seiten-Navigation nicht (Token liegt im localStorage, nicht im Cookie), daher + erfolgt das Gating clientseitig: der Header-Button erscheint nur fuer info@, + und studio.js leitet fremde Logins auf /dashboard um. + """ + return FileResponse(os.path.join(STATIC_DIR, "studio.html")) + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="127.0.0.1", port=8891) diff --git a/src/static/css/studio.css b/src/static/css/studio.css new file mode 100644 index 0000000..a872902 --- /dev/null +++ b/src/static/css/studio.css @@ -0,0 +1,1363 @@ +/* AegisSight Studio - NotebookLM-artiges 3-Spalten-Layout + Nutzt ausschliesslich die Design-Tokens aus style.css (Navy/Gold). */ + +.studio { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg-primary); + color: var(--text-primary); +} + +/* === Kopfzeile === */ +.studio-top { + display: flex; + align-items: center; + gap: var(--sp-lg); + padding: var(--sp-md) var(--sp-xl); + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + min-height: 52px; +} + +.studio-brand { + font-family: var(--font-title); + font-weight: 700; + font-size: 16px; + letter-spacing: 0.5px; + white-space: nowrap; +} +.studio-brand span { color: var(--accent); } + +/* Kopfzeile: Knopf zum Anlegen (an der Stelle des frueheren Dropdowns) + Titel des Falls */ +.studio-new-btn { flex-shrink: 0; } +.studio-incident-title { + font-family: var(--font-title); + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + max-width: 460px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.studio-type-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 3px 8px; + border-radius: var(--radius); + background: var(--tint-accent); + color: var(--accent); + white-space: nowrap; +} +.studio-type-badge.type-research { background: var(--tint-info); color: var(--info); } + +.studio-btn { + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + padding: 7px 14px; + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + transition: background 0.15s; +} +.studio-btn:hover { background: var(--accent-hover); } +.studio-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.studio-btn-ghost { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} +.studio-btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); } + +/* Pipeline-Statusstreifen */ +.studio-status { + display: none; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + background: var(--tint-accent-subtle); + border: 1px solid var(--tint-accent); + border-radius: var(--radius); + padding: 4px 10px; + max-width: 360px; + overflow: hidden; +} +.studio-status.active { display: flex; } +.studio-status .mini-spinner { + width: 12px; height: 12px; + border: 2px solid var(--tint-accent); + border-top-color: var(--accent); + border-radius: 50%; + animation: studio-spin 0.8s linear infinite; + flex-shrink: 0; +} +.studio-status-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.studio-status-timer { font-family: var(--font-mono); font-size: 11px; color: var(--text-disabled); } +@keyframes studio-spin { to { transform: rotate(360deg); } } + +.studio-top-right { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--sp-md); +} +.studio-top-right a { color: var(--text-secondary); font-size: 12px; text-decoration: none; } +.studio-top-right a:hover { color: var(--accent); } + +.studio-theme-toggle { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: var(--radius); + width: 32px; height: 32px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.studio-theme-toggle:hover { color: var(--accent); border-color: var(--accent); } + +/* === 3 Spalten === */ +.studio-cols { + position: relative; /* Bezug fuer die Hinweis-Auflage (.studio-empty) */ + flex: 1; + display: grid; + grid-template-columns: 300px 1fr 320px; + gap: 1px; + background: var(--border); + min-height: 0; +} + +.studio-col { + background: var(--bg-primary); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.studio-col-artifacts { background: var(--bg-secondary); } +.studio-col-center { position: relative; } /* Bezug fuer das Startpanel (.start-panel) */ + +/* === Linke Spalte: Reiter "Fälle" / "Quellen" === */ +.left-tabs { + display: flex; + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} +.left-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; + padding: var(--sp-lg) var(--sp-md); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-family: var(--font-title); + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-secondary); + transition: color .15s, border-color .15s, background .15s; +} +.left-tab:hover { background: var(--bg-hover); color: var(--text-primary); } +.left-tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.left-tab .lt-count { + font-family: var(--font-body); + font-weight: 500; + font-size: 11px; + text-transform: none; + letter-spacing: 0; + color: var(--text-disabled); +} +.left-pane { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + overflow: hidden; +} +.left-pane[hidden] { display: none; } + +/* Fall-Liste (ersetzt das Dropdown in der Kopfzeile) */ +.case-group { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: var(--sp-lg) var(--sp-md) var(--sp-sm); + background: none; + border: none; + font-size: 10px; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-disabled); + text-align: left; +} +.case-group span { color: var(--text-disabled); opacity: .8; } +.case-group-toggle { cursor: pointer; } +.case-group-toggle:hover { color: var(--text-secondary); } +.case-group-toggle svg { transition: transform .15s; } +.case-group-toggle.open svg { transform: rotate(90deg); } + +.case-item { + display: flex; + align-items: stretch; + border: 1px solid var(--border); + border-radius: var(--radius-md, 6px); + background: var(--bg-card); + margin-bottom: 4px; + overflow: hidden; + transition: border-color .15s, background .15s; +} +.case-item:hover { border-color: var(--accent); } +.case-item.active { border-color: var(--accent); background: var(--tint-accent-faint); } +.case-open { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 12.5px; +} +.case-item.active .case-open { color: var(--accent); font-weight: 600; } +.case-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.case-count { + flex-shrink: 0; + font-size: 10.5px; + color: var(--text-disabled); +} +/* === Startpanel: frisch angelegter Fall, der Nutzer entscheidet aktiv === */ +.start-panel { + position: absolute; + inset: 0; + z-index: 6; + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-xl); + background: var(--bg-primary); + overflow-y: auto; +} +.start-panel[hidden] { display: none; } +.sp-inner { width: 100%; max-width: 560px; } +.sp-head { margin-bottom: var(--sp-xl); text-align: center; } +.sp-title { + font-family: var(--font-title); + font-weight: 700; + font-size: 19px; + color: var(--text-primary); +} +.sp-sub { margin-top: 4px; font-size: 13px; color: var(--text-secondary); } + +.sp-warn { + display: flex; + align-items: flex-start; + gap: var(--sp-md); + padding: var(--sp-md) var(--sp-lg); + margin-bottom: var(--sp-lg); + border: 1px solid var(--warning); + border-radius: var(--radius-md, 6px); + background: var(--tint-warning); + color: var(--warning); + font-size: 12px; + line-height: 1.45; +} +.sp-warn[hidden] { display: none; } +.sp-warn svg { flex-shrink: 0; margin-top: 1px; } + +.sp-options { display: flex; flex-direction: column; gap: var(--sp-md); } +.sp-opt { + display: flex; + align-items: flex-start; + gap: var(--sp-lg); + width: 100%; + padding: var(--sp-lg); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + text-align: left; + color: var(--text-primary); + font-family: var(--font-body); + transition: border-color .15s, background .15s, transform .08s; +} +.sp-opt:hover { border-color: var(--accent); background: var(--bg-hover); } +.sp-opt:active { transform: translateY(1px); } +.sp-opt:disabled { opacity: .45; cursor: not-allowed; } +.sp-ico { flex-shrink: 0; display: inline-flex; color: var(--accent); margin-top: 2px; } +.sp-text { min-width: 0; } +.sp-name { + display: block; + font-family: var(--font-title); + font-weight: 600; + font-size: 13.5px; + margin-bottom: 3px; +} +.sp-desc { display: block; font-size: 12px; color: var(--text-secondary); line-height: 1.45; } + +/* Rueckfrage-Dialog (ersetzt das Browser-confirm) */ +.modal.modal-confirm { max-width: 460px; } +.confirm-list { + list-style: none; + margin: 0 0 var(--sp-lg); + padding: var(--sp-md) var(--sp-lg); + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md, 6px); + max-height: 190px; + overflow-y: auto; +} +.confirm-list li { + font-size: 12.5px; + color: var(--text-primary); + padding: 3px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.confirm-list li.cd-more { color: var(--text-disabled); font-style: italic; } +.confirm-warn { font-size: 13px; color: var(--text-primary); margin-bottom: var(--sp-md); } +.confirm-warn strong { color: var(--danger, #EF4444); } +.confirm-hint { font-size: 12px; color: var(--text-disabled); } + +/* Mehrfachauswahl. Achtung: .col-actions setzt display:flex und wuerde das + hidden-Attribut ueberstimmen - deshalb hier explizit ausblenden. */ +#case-select-row[hidden] { display: none; } +.case-check { + flex-shrink: 0; + display: flex; + align-items: center; + padding: 0 2px 0 8px; + cursor: pointer; +} +.case-check input { cursor: pointer; accent-color: var(--accent); } +.case-item.picked { border-color: var(--accent); background: var(--tint-accent); } +.case-sel-hint { font-size: 11px; color: var(--accent); } + +.bulk-bar { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--sp-md); + padding: var(--sp-lg); + border-top: 1px solid var(--border); + background: var(--bg-secondary); +} +.bulk-bar[hidden] { display: none; } +.bulk-count { + font-family: var(--font-title); + font-weight: 600; + font-size: 12px; + color: var(--text-primary); +} +.bulk-actions { display: flex; flex-wrap: wrap; gap: var(--sp-sm); } +.btn-bulk { + flex: 1; + min-width: 84px; + padding: 6px 10px; + border-radius: var(--radius-md, 6px); + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + transition: border-color .15s, background .15s, color .15s; +} +.btn-bulk:hover { border-color: var(--accent); color: var(--accent); } +.btn-bulk[hidden] { display: none; } +.btn-bulk.danger { color: var(--danger, #EF4444); } +.btn-bulk.danger:hover { border-color: var(--danger, #EF4444); background: var(--tint-error, rgba(239,68,68,.12)); } +.btn-bulk.ghost { color: var(--text-disabled); font-weight: 500; } +.btn-bulk.ghost:hover { color: var(--text-primary); border-color: var(--border); } + +.case-menu-btn { + flex-shrink: 0; + width: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-left: 1px solid var(--border); + color: var(--text-disabled); + cursor: pointer; +} +.case-menu-btn:hover { color: var(--accent); background: var(--bg-hover); } + +.case-menu { + position: fixed; + z-index: 300; + min-width: 170px; + padding: 4px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg, 8px); + box-shadow: 0 8px 28px rgba(0,0,0,.28); +} +.case-menu button { + display: block; + width: 100%; + padding: 7px 10px; + background: none; + border: none; + border-radius: var(--radius-md, 6px); + text-align: left; + cursor: pointer; + font-family: var(--font-body); + font-size: 12.5px; + color: var(--text-primary); +} +.case-menu button:hover { background: var(--bg-hover); } +.case-menu button.danger { color: var(--danger, #EF4444); } +.case-menu button.danger:hover { background: var(--tint-error, rgba(239,68,68,.12)); } + +.col-head { + display: flex; + align-items: center; + gap: 8px; + padding: var(--sp-lg) var(--sp-xl); + font-family: var(--font-title); + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.col-head .col-count { + margin-left: auto; + font-size: 11px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; + color: var(--text-disabled); +} +.col-body { + flex: 1; + overflow-y: auto; + padding: var(--sp-lg) var(--sp-xl); + min-height: 0; +} + +/* === Spalte 1: Quellen === */ +.col-actions { + display: flex; + align-items: center; + gap: 8px; + padding: var(--sp-md) var(--sp-xl); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.col-actions label { + display: flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--text-secondary); cursor: pointer; +} +.col-actions input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; } +.col-actions .spacer { margin-left: auto; } +.ingest-add-btn { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; padding: 4px 10px; } + +/* --- Quellen-Ingest: Dropzone + Job-Liste --- */ +.ingest-panel { + padding: var(--sp-md) var(--sp-xl); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.dropzone { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 4px; text-align: center; + padding: 16px 12px; + border: 1.5px dashed var(--border); + border-radius: 10px; + color: var(--text-secondary); + cursor: pointer; + transition: border-color .15s, background .15s; +} +.dropzone:hover, .dropzone.drag { border-color: var(--accent); background: var(--bg-hover); } +.dropzone .dz-icon { color: var(--accent); } +.dropzone .dz-hint { font-size: 12.5px; color: var(--text-primary); } +.dropzone .dz-sub { font-size: 11px; color: var(--text-disabled); } +.ingest-url { display: flex; gap: 6px; margin-top: 8px; } +.ingest-url input { + flex: 1; min-width: 0; font-size: 12px; + padding: 6px 10px; border-radius: 8px; + border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); +} +.ingest-url input:focus { outline: none; border-color: var(--accent); } + +.ingest-jobs { flex-shrink: 0; overflow-y: auto; max-height: 40%; padding: 0 var(--sp-xl); } +.ingest-jobs:not(:empty) { padding-top: var(--sp-sm); padding-bottom: var(--sp-sm); border-bottom: 1px solid var(--border); } +.ju-group { margin-bottom: 8px; } +.ju-group-head { + display: flex; align-items: center; gap: 6px; + font-size: 11px; font-weight: 600; letter-spacing: .02em; + color: var(--text-secondary); text-transform: uppercase; + margin: 4px 0; +} +.ju-group-head .ju-count { + margin-left: auto; background: var(--bg-card); border: 1px solid var(--border); + border-radius: 8px; padding: 0 6px; font-weight: 500; text-transform: none; +} +.ju-item { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: 8px; padding: 7px 9px; margin-bottom: 5px; +} +.ju-item.error { border-color: var(--danger, #d9534f); } +.ju-row { display: flex; align-items: center; gap: 6px; } +.ju-name { flex: 1; min-width: 0; font-size: 12.5px; color: var(--text-primary); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ju-del { background: none; border: none; color: var(--text-disabled); font-size: 16px; + line-height: 1; cursor: pointer; padding: 0 2px; } +.ju-del:hover { color: var(--danger, #d9534f); } +.ju-bar { height: 4px; background: var(--bg-hover); border-radius: 3px; overflow: hidden; margin-top: 6px; } +.ju-bar span { display: block; height: 100%; background: var(--accent); transition: width .4s ease; } +.ju-stage { font-size: 10.5px; color: var(--text-disabled); margin-top: 3px; } +.ju-err { font-size: 11px; color: var(--danger, #d9534f); margin-top: 4px; } +.ju-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; } +.ju-btn { + font-size: 11px; padding: 3px 9px; border-radius: 12px; + border: 1px solid var(--border); background: var(--bg-elevated, var(--bg-card)); + color: var(--text-secondary); cursor: pointer; text-decoration: none; +} +.ju-btn:hover { border-color: var(--accent); color: var(--accent); } +.ju-media:not(:empty) { margin-top: 6px; } +.ju-text { + font-size: 12px; line-height: 1.5; color: var(--text-secondary); + background: var(--bg-hover); border-radius: 8px; padding: 8px 10px; + max-height: 220px; overflow-y: auto; white-space: normal; +} + +/* Kategorie-Gliederung der regulären Quellenliste (Webseiten/Dokumente/…) */ +.src-cat { margin-bottom: 2px; } +.src-cat-head { + display: flex; align-items: center; gap: 6px; + padding: 10px var(--sp-xl) 4px; + font-size: 11px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; + color: var(--text-secondary); + cursor: pointer; user-select: none; +} +.src-cat-head:hover { color: var(--text-primary); } +.src-cat-head svg.src-cat-icon, .src-cat-head > svg:not(.src-cat-chevron) { color: var(--accent); } +.src-cat-head .src-cat-count { margin-left: auto; color: var(--text-disabled); font-weight: 500; } +.src-cat-chevron { color: var(--text-disabled); transition: transform .15s ease; flex-shrink: 0; } +.src-cat.collapsed .src-cat-chevron { transform: rotate(-90deg); } +.src-cat.collapsed .src-item { display: none; } +.src-upload { display: flex; flex-direction: column; gap: 4px; } +.src-upload .ju-actions { margin-top: 2px; } +.ju-btn-del { color: var(--text-disabled); } +.ju-btn-del:hover { border-color: var(--danger, #d9534f); color: var(--danger, #d9534f); } + +/* Historie (frühere Lageberichte / Faktencheck-Läufe, aufklappbar) */ +.hist-item { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 6px; background: var(--bg-card); } +.hist-item > summary { + list-style: none; cursor: pointer; padding: 8px 10px; + display: flex; align-items: center; gap: 10px; font-size: 12.5px; +} +.hist-item > summary::-webkit-details-marker { display: none; } +.hist-item > summary::before { + content: "\25B8"; color: var(--text-disabled); transition: transform .15s; font-size: 11px; +} +.hist-item[open] > summary::before { transform: rotate(90deg); } +.hist-time { color: var(--text-primary); font-weight: 600; } +.hist-meta { color: var(--text-disabled); margin-left: auto; } +.hist-body { padding: 4px 12px 12px; } +.hist-prev { font-size: 12px; color: var(--text-secondary); line-height: 1.5; } +.fc-history { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 10px; } +.fc-history > summary { + cursor: pointer; font-size: 12px; font-weight: 600; color: var(--text-secondary); + list-style: none; padding: 2px 0; +} +.fc-history > summary::-webkit-details-marker { display: none; } +.fc-history > summary::before { content: "\25B8 "; color: var(--text-disabled); } +.fc-history[open] > summary::before { content: "\25BE "; } +.fc-history-body { margin-top: 8px; } + +/* Quellen-Suchfeld (oberster Punkt der Quellen-Spalte) */ +.src-search { + display: flex; + align-items: center; + gap: 8px; + padding: var(--sp-md) var(--sp-xl); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.src-search-icon { + display: inline-flex; + align-items: center; + color: var(--text-disabled); + flex-shrink: 0; +} +.src-search input { + flex: 1; + min-width: 0; + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: var(--radius); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; + padding: 7px 10px; +} +.src-search input:focus { outline: none; border-color: var(--accent); } +.src-item.filtered-out { display: none; } + +.src-item { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 6px; + background: var(--bg-card); + overflow: hidden; +} +.src-item.deselected { opacity: 0.45; } +.src-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + cursor: pointer; +} +.src-head:hover { background: var(--bg-hover); } +.src-check { accent-color: var(--accent); cursor: pointer; flex-shrink: 0; } +.src-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.src-count { + font-size: 11px; + color: var(--text-disabled); + background: var(--bg-elevated); + border-radius: 10px; + padding: 1px 7px; + flex-shrink: 0; +} +.src-badges { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } +.src-lang { font-size: 10px; color: var(--text-disabled); flex-shrink: 0; } + +.src-articles { + display: none; + border-top: 1px solid var(--border); + padding: 4px 10px 8px; +} +.src-item.open .src-articles { display: block; } +.src-article { + display: block; + font-size: 12px; + color: var(--text-secondary); + padding: 4px 0; + text-decoration: none; + border-bottom: 1px solid var(--tint-hover-subtle); + line-height: 1.4; +} +.src-article:last-child { border-bottom: none; } +.src-article:hover { color: var(--accent); } +.src-article .src-article-date { color: var(--text-disabled); font-size: 10px; margin-right: 6px; } + +/* Quelle beim Zitat-Klick hervorheben */ +.src-item.cite-flash { + animation: cite-flash 1.6s ease; + border-color: var(--accent); +} +@keyframes cite-flash { + 0%, 30% { box-shadow: 0 0 0 2px var(--accent), var(--glow-accent); } + 100% { box-shadow: none; } +} + +/* Reliability-/Bias-Badges (Wiederverwendung der style.css-Klassen, hier nur Ausrichtung) */ +.src-badges .source-reliability-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; } +.src-badges .source-political-badge, +.src-badges .source-ifcn-badge, +.src-badges .source-eu-disinfo-badge, +.src-badges .source-state-badge, +.src-badges .source-alignment-chip-badge { font-size: 9px; } + +/* === Spalte 2: Konversation === */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: var(--sp-lg) var(--sp-xl); + display: flex; + flex-direction: column; + gap: var(--sp-lg); + min-height: 0; +} +.chat-msg { max-width: 92%; } +.chat-msg-user { + align-self: flex-end; + background: var(--accent); + color: #fff; + border-radius: 12px 12px 2px 12px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.5; +} +.chat-msg-assistant { + align-self: flex-start; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px 12px 12px 2px; + padding: 10px 14px; + font-size: 13px; + line-height: 1.6; + color: var(--text-primary); +} +.chat-msg-assistant.pinned { + border-color: var(--tint-accent); + background: var(--tint-accent-faint); + max-width: 100%; + align-self: stretch; +} +.chat-msg-assistant .briefing-content { font-size: 13px; } +.chat-pin-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; + color: var(--accent); font-weight: 600; margin-bottom: 6px; +} +.chat-cite { + display: inline-block; + color: var(--accent); + font-weight: 600; + cursor: pointer; + font-size: 11px; + vertical-align: super; + padding: 0 1px; +} +.chat-cite:hover { text-decoration: underline; } +.chat-thinking { color: var(--text-disabled); font-style: italic; font-size: 12px; } + +.chat-suggestions { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 var(--sp-xl) var(--sp-md); + flex-shrink: 0; +} +.chat-chip { + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: 14px; + padding: 5px 12px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} +.chat-chip:hover { border-color: var(--accent); color: var(--accent); } + +.chat-scope { + display: flex; + align-items: center; + gap: 6px; + padding: 0 var(--sp-xl) var(--sp-sm); + font-size: 12px; + color: var(--text-disabled); + cursor: pointer; + user-select: none; + flex-shrink: 0; +} +.chat-scope input { accent-color: var(--accent); cursor: pointer; margin: 0; } +.chat-scope:hover { color: var(--text-secondary); } +.chat-scope.on { color: var(--accent); } + +.chat-input-row { + display: flex; + gap: 8px; + padding: var(--sp-md) var(--sp-xl) var(--sp-lg); + border-top: 1px solid var(--border); + flex-shrink: 0; +} +.chat-input-row textarea { + flex: 1; + resize: none; + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; + padding: 10px 12px; + max-height: 120px; + line-height: 1.5; +} +.chat-input-row textarea:focus { outline: none; border-color: var(--accent); } +.chat-send { + align-self: flex-end; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius-lg); + width: 40px; height: 40px; + font-size: 16px; + cursor: pointer; + flex-shrink: 0; +} +.chat-send:hover { background: var(--accent-hover); } +.chat-send:disabled { opacity: 0.5; cursor: not-allowed; } + +/* === Spalte 2: Mitte = Tab-Panel (oben) + Konversation (unten) === */ +.studio-col-center { min-height: 0; } + +/* Tab-Panel: nur sichtbar, wenn mindestens ein Studio-Teil geoeffnet ist */ +.center-tabs { + display: none; + flex-direction: column; + min-height: 0; +} +.studio-col-center.has-tabs .center-tabs { display: flex; flex: 1 1 auto; min-height: 120px; } + +.center-tabbar { + display: flex; + align-items: flex-end; + gap: 4px; + padding: 6px 8px 0; + overflow-x: auto; + flex-shrink: 0; + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.35); + position: relative; + z-index: 1; +} +.center-tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 8px 7px 12px; + border: 1px solid var(--border); + border-top: 2px solid transparent; + border-bottom: none; + border-radius: var(--radius) var(--radius) 0 0; + background: var(--bg-secondary); + color: var(--text-secondary); + font-family: var(--font-title); + font-size: 12px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + max-width: 210px; + margin-bottom: 0; + transition: background 0.15s, color 0.15s; +} +.center-tab:hover { background: var(--bg-hover); color: var(--text-primary); } +.center-tab .ct-icon { display: inline-flex; align-items: center; color: var(--accent); flex-shrink: 0; } +.center-tab .ct-icon svg { width: 14px; height: 14px; } +.center-tab .ct-title { overflow: hidden; text-overflow: ellipsis; } +.center-tab.active { + color: var(--text-primary); + background: var(--bg-primary); + border-color: var(--border); + border-top: 2px solid var(--accent); + margin-bottom: -1px; /* verbindet den aktiven Tab mit dem Inhalt darunter */ +} +/* Drag&Drop-Umsortierung */ +.center-tab.dragging-tab { opacity: 0.45; } +.center-tab.drop-before { box-shadow: inset 2px 0 0 var(--accent); } +.center-tab.drop-after { box-shadow: inset -2px 0 0 var(--accent); } +.center-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 17px; + border-radius: 4px; + color: var(--text-disabled); + flex-shrink: 0; +} +.center-tab-close:hover { background: var(--bg-hover); color: var(--text-primary); } + +.center-tab-content { + flex: 1; + min-height: 0; + display: flex; + background: var(--bg-primary); +} +.tab-panel { + display: none; + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--sp-xl); + font-size: 13px; + line-height: 1.7; +} +.tab-panel.active { display: block; } +.tab-panel[data-art="map"] { padding: 0; } +/* Grosszuegigere Abstaende, damit Bericht/Zusammenfassung nicht gequetscht wirken */ +.tab-panel h3, +.tab-panel .briefing-heading { margin: 18px 0 8px; font-size: 14px; } +.tab-panel > *:first-child, +.tab-panel .briefing-content > *:first-child { margin-top: 0; } +.tab-panel ul { margin: 10px 0 14px 20px; } +.tab-panel li { margin-bottom: 7px; } +.tab-panel p { margin: 10px 0; } + +/* Verschiebbare Trennung zwischen Tab-Panel und Konversation */ +.center-divider { + display: none; + flex: 0 0 12px; + height: 12px; + align-items: center; + justify-content: center; + cursor: row-resize; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + touch-action: none; + flex-shrink: 0; +} +.studio-col-center.has-tabs .center-divider { display: flex; } +.center-divider::before { + content: ""; + width: 44px; + height: 3px; + border-radius: 3px; + background: var(--text-disabled); + opacity: 0.55; + transition: background 0.15s, opacity 0.15s; +} +.center-divider:hover::before, +.center-divider.dragging::before { background: var(--accent); opacity: 1; } + +/* Konversation unten (immer sichtbar) */ +.chat-region { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; +} +.studio-col-center.has-tabs .chat-region { flex: 0 0 var(--chat-h, 42%); min-height: 150px; } + +/* === Spalte 3: Studio-Menue (oeffnet Tabs in der Mitte) === */ +.studio-menu { padding: var(--sp-md); } +.studio-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 10px 12px; + margin-bottom: 8px; + cursor: pointer; + text-align: left; + color: var(--text-primary); + font-family: var(--font-body); + transition: background 0.15s, border-color 0.15s; +} +.studio-menu-item:hover { background: var(--bg-hover); border-color: var(--accent); } +.studio-menu-item.open { border-color: var(--tint-accent); } +.studio-menu-item.active { border-color: var(--accent); background: var(--tint-accent-faint); } +.studio-menu-item .mi-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); +} +.studio-menu-item .mi-title { + flex: 1; + min-width: 0; + font-family: var(--font-title); + font-weight: 600; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.studio-menu-item .mi-meta { font-size: 11px; color: var(--text-disabled); flex-shrink: 0; } + +/* === Spalte 3: Artefakt-Karten (Ergebnis UND Erzeuger in einem Element) === + Klick auf den Kopf oeffnet das Ergebnis in der Mitte, das Symbol rechts rechnet + es neu, die Zeile darunter meldet den Datenstand (aktuell / veraltet / laeuft). */ +@keyframes st-spin { to { transform: rotate(360deg); } } + +/* Kompletter Lauf: orchestriert alles, daher als Primaerknopf ueber den Karten */ +.run-all { + display: flex; align-items: center; justify-content: center; gap: 8px; + width: 100%; + padding: 9px 12px; + margin-bottom: 12px; + border-radius: var(--radius-md, 6px); + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + font-family: var(--font-title); + font-weight: 600; + font-size: 12.5px; + cursor: pointer; + transition: background .15s, opacity .15s; +} +.run-all:hover:not(:disabled) { background: var(--accent-hover); } +.run-all:disabled { opacity: .45; cursor: not-allowed; } +/* display:flex wuerde das hidden-Attribut ueberstimmen - explizit ausblenden */ +.run-all[hidden] { display: none; } +.run-all .ra-icon { display: inline-flex; } +.run-all .ra-state { display: none; } +.run-all.running .ra-icon { display: none; } +.run-all.running .ra-state { + display: inline-flex; align-items: center; gap: 6px; font-weight: 400; font-size: 11.5px; +} +.run-all.running .ra-state::before { + content: ''; width: 11px; height: 11px; flex-shrink: 0; + border: 2px solid currentColor; border-right-color: transparent; + border-radius: 50%; animation: st-spin .8s linear infinite; +} + +/* Die Karte selbst */ +.art-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 8px; + overflow: hidden; + transition: border-color .15s; +} +.art-card:hover { border-color: var(--accent); } +.art-card:has(.studio-menu-item.active) { border-color: var(--accent); } +.art-card.busy { border-color: var(--accent); } +.art-card-soon { opacity: .6; } +.art-row { display: flex; align-items: stretch; } + +/* Kartenkopf: uebernimmt die Rolle des alten Menue-Eintrags, aber ohne eigenen Rahmen */ +.studio-menu .art-head { + flex: 1; min-width: 0; + display: flex; align-items: center; gap: 10px; + padding: 10px var(--sp-lg); + margin: 0; + background: none; + border: none; + border-radius: 0; + text-align: left; + cursor: pointer; + color: var(--text-primary); + font-family: var(--font-body); +} +.studio-menu .art-head:hover { background: var(--bg-hover); } +.studio-menu .art-head.active { background: var(--tint-accent-faint); } +.studio-menu .art-head-static { cursor: default; } +.studio-menu .art-head-static:hover { background: none; } +.art-head .mi-icon { flex-shrink: 0; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; color: var(--accent); } +.art-head .mi-text { flex: 1; min-width: 0; } +.art-head .mi-title { + display: block; + font-family: var(--font-title); + font-weight: 600; + font-size: 13px; + line-height: 1.25; + white-space: normal; + overflow: visible; + text-overflow: clip; +} +.art-head .mi-meta { display: block; margin-top: 1px; font-size: 10.5px; color: var(--text-disabled); } + +/* Erzeuger-Aktion (rechnet das Ergebnis neu) */ +.art-run { + flex-shrink: 0; + width: 36px; + display: flex; align-items: center; justify-content: center; + background: none; + border: none; + border-left: 1px solid var(--border); + color: var(--text-disabled); + cursor: pointer; + transition: color .15s, background .15s; +} +.art-run:hover:not(:disabled) { color: var(--accent); background: var(--bg-hover); } +.art-run:disabled { opacity: .35; cursor: not-allowed; } + +/* Datenstand-Zeile */ +.art-note { + display: none; + align-items: center; + gap: var(--sp-md); + padding: 7px var(--sp-lg); + border-top: 1px solid var(--border); + font-size: 11px; + line-height: 1.35; +} +.art-note.show { display: flex; } +.art-note.fresh { background: var(--tint-success); color: var(--success); } +.art-note.stale { background: var(--tint-warning); color: var(--warning); } +.art-note.blocked { background: var(--bg-elevated); color: var(--text-disabled); } +.art-note.running { background: var(--tint-accent); color: var(--accent); } +.art-note .an-spin { + width: 11px; height: 11px; flex-shrink: 0; + border: 2px solid currentColor; border-right-color: transparent; + border-radius: 50%; animation: st-spin .8s linear infinite; +} +.art-note button { + margin-left: auto; + white-space: nowrap; + padding: 3px 9px; + font-size: 10.5px; + font-weight: 600; + border-radius: var(--radius-md, 6px); + border: 1px solid currentColor; + background: transparent; + color: inherit; + cursor: pointer; + font-family: var(--font-body); +} +.art-note button:hover { background: var(--tint-hover-subtle); } + +/* Unter-Ansichten: gehoeren zum selben Erzeuger (Analyse) */ +.art-subs { border-top: 1px solid var(--border); } +.studio-menu .art-sub { + display: flex; align-items: center; gap: 8px; + width: 100%; + padding: 6px var(--sp-lg); + margin: 0; + background: none; + border: none; + border-radius: 0; + cursor: pointer; + text-align: left; + color: var(--text-secondary); + font-family: var(--font-body); + font-size: 11.5px; +} +.studio-menu .art-sub:hover { background: var(--bg-hover); color: var(--text-primary); } +.studio-menu .art-sub.active { background: var(--tint-accent-faint); color: var(--accent); } +.art-sub .mi-icon { flex-shrink: 0; color: var(--text-disabled); display: inline-flex; } +.art-sub.active .mi-icon { color: var(--accent); } +.art-sub .mi-title { + flex: 1; min-width: 0; + font-family: var(--font-body); + font-weight: 500; + font-size: 11.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.art-sub .mi-meta { flex-shrink: 0; font-size: 10px; color: var(--text-disabled); } + +/* Export: Ausgabe-Aktion, kein Artefakt -> Fusszeile */ +.art-foot { margin-top: var(--sp-lg); padding-top: var(--sp-lg); border-top: 1px solid var(--border); } +.studio-menu .art-export { justify-content: center; gap: 8px; margin-bottom: 0; padding: 8px; font-size: 12px; } +.art-export .mi-title { font-family: var(--font-body); font-weight: 600; font-size: 12px; flex: 0 0 auto; } +.art-export .mi-icon { color: var(--accent); display: inline-flex; } + +/* "Sammeln" in der Quellenspalte (sein Ergebnis landet dort) */ +.collect-btn:disabled { opacity: .45; cursor: not-allowed; } +.collect-btn.running .cb-label { display: none; } +.collect-btn.running::after { + content: 'Sammelt …'; + display: inline-flex; align-items: center; +} + +/* Karten-Container fuellt den Karte-Tab */ +#map-container { + width: 100%; + height: 100%; + min-height: 320px; + overflow: hidden; +} +#map-empty { + display: none; + align-items: center; + justify-content: center; + height: 120px; + color: var(--text-disabled); + font-size: 13px; +} + +/* Timeline (schlank) */ +.tl-item { + padding: 6px 0; + border-bottom: 1px solid var(--tint-hover-subtle); + font-size: 12px; +} +.tl-item:last-child { border-bottom: none; } +.tl-time { color: var(--text-disabled); font-size: 11px; margin-right: 8px; font-family: var(--font-mono); } +.tl-item a { color: var(--text-secondary); text-decoration: none; } +.tl-item a:hover { color: var(--accent); } + +/* Aktivitaets-/Ereignis-Timeline (in Untersektionen gegliedert) */ +.ev-section { border-bottom: 1px solid var(--border); } +.ev-section:last-child { border-bottom: none; } +.ev-section-head { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 2px; + cursor: pointer; + user-select: none; + list-style: none; +} +.ev-section-head::-webkit-details-marker { display: none; } +.ev-section-head:hover .ev-section-title { color: var(--accent); } +.ev-section-icon { display: inline-flex; align-items: center; color: var(--accent); flex-shrink: 0; } +.ev-section-icon svg { width: 16px; height: 16px; } +.ev-section-title { + flex: 1; + font-family: var(--font-title); + font-weight: 600; + font-size: 13px; + color: var(--text-primary); +} +.ev-section-count { + font-size: 11px; + color: var(--text-disabled); + background: var(--bg-elevated); + border-radius: 10px; + padding: 1px 8px; + flex-shrink: 0; +} +.ev-section-head::after { + content: "\25B8"; + display: inline-block; + color: var(--text-secondary); + font-size: 10px; + transition: transform 0.15s; +} +.ev-section[open] .ev-section-head::after { transform: rotate(90deg); } +.ev-section-body { padding: 2px 0 10px 6px; } + +/* Sektions-Akzente (Icon-Farbe je Typ) */ +.sec-refresh .ev-section-icon { color: var(--info); } +.sec-source_change .ev-section-icon { color: var(--success); } +.sec-article_ingest .ev-section-icon { color: var(--text-secondary); } + +/* Einzel-Ereignis (kompakt: Zeit links, Inhalt rechts) */ +.ev-item { + display: flex; + gap: 10px; + padding: 7px 0; + border-bottom: 1px solid var(--tint-hover-subtle); +} +.ev-item:last-child { border-bottom: none; } +.ev-time { + font-size: 11px; + color: var(--text-disabled); + font-family: var(--font-mono); + flex-shrink: 0; + min-width: 78px; + padding-top: 1px; +} +.ev-main { flex: 1; min-width: 0; } +.ev-title { font-size: 12px; line-height: 1.5; color: var(--text-primary); word-break: break-word; } +.ev-title a { color: var(--text-secondary); text-decoration: none; } +.ev-title a:hover { color: var(--accent); text-decoration: underline; } +.ev-src { font-size: 11px; color: var(--text-disabled); margin-top: 2px; } + +/* Q&A: ausklappbare Antwort */ +.ev-answer { margin-top: 6px; } +.ev-answer summary { + cursor: pointer; + font-size: 12px; + color: var(--accent); + list-style: none; + user-select: none; +} +.ev-answer summary::-webkit-details-marker { display: none; } +.ev-answer summary::before { content: "\25B8 "; } +.ev-answer[open] summary::before { content: "\25BE "; } +.ev-answer-body { + margin-top: 6px; + padding: 8px 10px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); +} + +/* Faktencheck im Tab */ +.tab-panel .factcheck-item { padding: 8px 0; border-bottom: 1px solid var(--tint-hover-subtle); } +.tab-panel .factcheck-item:last-child { border-bottom: none; } + +/* Export-Karte */ +.export-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.export-row select { + background: var(--input-bg); color: var(--text-primary); + border: 1px solid var(--input-border); border-radius: var(--radius); + padding: 6px 10px; font-size: 12px; +} + +/* Empty / Leerzustaende */ +/* Ohne offenen Fall: Mitte + rechte Spalte bleiben SICHTBAR, nur ausgegraut und + nicht bedienbar. Der Hinweis liegt als Auflage darueber (Spalte 1 bleibt aktiv). */ +.studio-cols.idle .studio-col-center, +.studio-cols.idle .studio-col-artifacts { + opacity: .32; + filter: grayscale(.55); + pointer-events: none; + user-select: none; +} +.studio-empty { + position: absolute; + top: 0; + bottom: 0; + left: 301px; /* rechts neben der Fall-Liste (300px + 1px Fuge) */ + right: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-xl); + pointer-events: none; /* nur Hinweis, faengt keine Klicks ab */ +} +.se-box { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 22px 28px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 34px rgba(0, 0, 0, .28); + color: var(--text-secondary); + font-size: 14px; + text-align: center; + max-width: 340px; +} +.se-box svg { color: var(--accent); } +.tab-panel .empty-hint { color: var(--text-disabled); font-size: 12px; } + +/* === Responsiv === */ +@media (max-width: 1100px) { + .studio-cols { grid-template-columns: 1fr; grid-auto-rows: minmax(0, 1fr); } + .studio-col { max-height: 60vh; } +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index a13a366..32b96ef 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -80,6 +80,7 @@
+ diff --git a/src/static/js/api.js b/src/static/js/api.js index 328b194..b2880ac 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -217,6 +217,78 @@ const API = { return this._request('GET', `/incidents/${incidentId}/refresh-log?limit=${limit}`); }, + // === Studio: Ereignis-Timeline, RAG-Chat, modulare Bausteine, Uploads, Faktencheck-Verlauf === + // Backend-Endpunkte folgen phasenweise; fehlende liefern vorerst 404 (studio.js faengt das ab). + getEvents(incidentId, limit = 250) { + return this._request('GET', `/incidents/${incidentId}/events?limit=${encodeURIComponent(limit)}`); + }, + + // RAG-Chat: inhaltliche Frage über eine konkrete Lage (Studio-UI) + askIncident(incidentId, message, { conversation_id = null, source_filter = null, scope = 'fall' } = {}) { + return this._request('POST', `/incidents/${incidentId}/ask`, { + message, + conversation_id, + source_filter, + scope, + }); + }, + + // Multimodaler Quellen-Ingest (Upload/URL -> Artikel) + createUploads(incidentId, { files = [], url = null } = {}) { + const fd = new FormData(); + (files || []).forEach(f => fd.append('files', f)); + if (url) fd.append('url', url); + return this.upload(`/incidents/${incidentId}/uploads`, fd); + }, + listUploads(incidentId) { + return this._request('GET', `/incidents/${incidentId}/uploads`); + }, + deleteUpload(incidentId, uploadId) { + return this._request('DELETE', `/incidents/${incidentId}/uploads/${uploadId}`); + }, + async fetchUploadBlobUrl(incidentId, uploadId) { + const token = localStorage.getItem('osint_token'); + const headers = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${this.baseUrl}/incidents/${incidentId}/uploads/${uploadId}/file`, { headers }); + if (!res.ok) throw new Error(`Datei konnte nicht geladen werden (${res.status})`); + const blob = await res.blob(); + return URL.createObjectURL(blob); + }, + + // Modulare Pipeline-Bausteine (Studio) + runStage(incidentId, stage) { + return this._request('POST', `/incidents/${incidentId}/run/${stage}`); + }, + getRunStatus(incidentId) { + return this._request('GET', `/incidents/${incidentId}/run-status`); + }, + // Datenstand je Artefakt (erzeugt? wie alt? wie viele neue Artikel seither?) + getFreshness(incidentId) { + return this._request('GET', `/incidents/${incidentId}/freshness`); + }, + // Kann die Websuche gerade Treffer liefern? (online via Claude-WebSearch, Stub) + getSearchStatus() { + return this._request('GET', '/system/search-status'); + }, + listFactcheckRuns(incidentId) { + return this._request('GET', `/incidents/${incidentId}/factcheck-runs`); + }, + getFactcheckRun(incidentId, runId) { + return this._request('GET', `/incidents/${incidentId}/factcheck-runs/${runId}`); + }, + + // X-Zugänge (Studio; online-Router folgt in spaeterer Phase) + listXAccounts() { + return this._request('GET', '/x/accounts'); + }, + addXAccount(data) { + return this._request('POST', '/x/accounts', data); + }, + deleteXAccount(username) { + return this._request('DELETE', `/x/accounts/${encodeURIComponent(username)}`); + }, + // Sources (Quellenverwaltung) listSources(params = {}) { const query = new URLSearchParams(); diff --git a/src/static/js/app.js b/src/static/js/app.js index 20fd75c..f59e99b 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -453,6 +453,12 @@ const App = { this.user = user; this._currentUsername = user.email; + // Studio-Button vorerst nur fuer info@aegis-sight.de sichtbar (experimentelle Ansicht) + if (user.email === 'info@aegis-sight.de') { + const studioLink = document.getElementById('studio-link'); + if (studioLink) studioLink.style.display = ''; + } + // i18n: Sprache anhand der Org laden (default 'de') und DOM uebersetzen if (window.I18N) { const targetLang = user.output_language || 'de'; diff --git a/src/static/js/studio.js b/src/static/js/studio.js new file mode 100644 index 0000000..dd34794 --- /dev/null +++ b/src/static/js/studio.js @@ -0,0 +1,2023 @@ +/** + * AegisSight Studio - NotebookLM-artiger Controller. + * + * Wiederverwendet die reinen Render-Funktionen aus components.js (UI.*) und den + * API-Client (API.*). Das globale `App`-Shim stellt nur die wenigen Felder/Handler + * bereit, die einige geliehene Render-Funktionen per onclick erwarten. + */ + +/* --- App-Shim (Minimal-Kompatibilitaet für components.js) --- */ +const App = { + currentIncidentId: null, + _fcHidden: new Set(), + + // Faktencheck-Statusfilter (von UI.renderFactCheckFilters referenziert) + toggleFactCheckFilter(status) { + if (this._fcHidden.has(status)) this._fcHidden.delete(status); + else this._fcHidden.add(status); + document.querySelectorAll('#art-fc-body .factcheck-item').forEach(el => { + el.style.display = this._fcHidden.has(el.dataset.fcStatus) ? 'none' : ''; + }); + }, + toggleFcDropdown(event) { + if (event) event.stopPropagation(); + const menu = document.getElementById('fc-dropdown-menu'); + if (menu) menu.classList.toggle('open'); + }, + // Stubs (von manchen Render-Funktionen referenziert, hier nicht genutzt) + toggleSourceOverviewDetail() {}, + syncRefreshStatus() { Studio.syncRefreshStatus(); }, +}; + +/* --- Studio-Controller --- */ +const Studio = { + incidents: [], + incident: null, + articles: [], + sourcesMeta: [], // Cache von API.listSources() für Badges + selected: null, // Set aktiver Quellen-Namen; null = alle aktiv + convId: null, + _startTime: null, + _timerInt: null, + openTabs: [], // in der Mitte geöffnete Studio-Teile (Reihenfolge) + activeTab: null, // aktuell sichtbarer Tab + _closeSvg: '', + // Ereignistypen der Aktivitaets-Timeline: Label + Lucide-Icon + _evMeta: { + article_ingest: { label: 'Meldung', icon: '' }, + refresh: { label: 'Aktualisierung', icon: '' }, + analysis: { label: 'Analyse', icon: '' }, + chat_qa: { label: 'Frage & Antwort', icon: '' }, + source_change: { label: 'Quelle', icon: '' }, + }, + // Untersektionen der Timeline (Reihenfolge + Sektions-Ueberschrift) + _evSections: [ + { key: 'chat_qa', label: 'Konversation' }, + { key: 'source_change', label: 'Quellen' }, + { key: 'refresh', label: 'Aktualisierungen' }, + { key: 'analysis', label: 'Analyse' }, + { key: 'article_ingest', label: 'Meldungen' }, + ], + + caseFilter: '', + _archiveOpen: false, + caseSel: new Set(), // Mehrfachauswahl in der Fall-Liste (IDs) + caseMode: null, // null = normale Liste, 'archive' | 'delete' = Auswahlmodus + + async init() { + if (!localStorage.getItem('osint_token')) { window.location.href = '/'; return; } + try { + const me = await API.getMe(); + // Studio ist vorerst nur fuer info@aegis-sight.de freigegeben. + if (!me || me.email !== 'info@aegis-sight.de') { window.location.href = '/dashboard'; return; } + } catch (e) { /* 401 leitet in api.js um */ } + WS.connect(); + this._wireWs(); + this._initDivider(); + this._initTabDnD(); + await this.loadIncidents(); + const last = localStorage.getItem('studio_incident'); + if (last && this.incidents.some(i => String(i.id) === String(last))) { + this.selectIncident(last); + } else { + // Ohne offenen Fall zeigt die linke Spalte die Fall-Auswahl + this._showEmpty(true); + this.leftTab('cases'); + } + }, + + /** + * Ohne offenen Fall bleiben Mitte und rechte Spalte leer - die LINKE Spalte + * muss aber stehen bleiben, sonst käme man an die Fall-Auswahl nicht heran. + */ + _showEmpty(on) { + // Mitte + rechte Spalte bleiben stehen, werden aber ausgegraut und + // unbedienbar (Klasse 'idle'). Nur der Hinweis legt sich darueber. + const cols = document.getElementById('studio-cols'); + if (cols) cols.classList.toggle('idle', on); + document.getElementById('studio-empty').style.display = on ? '' : 'none'; + }, + + // === Linke Spalte: Reiter "Fälle" / "Quellen" === + leftTab(which) { + const cases = which === 'cases'; + // Beim Verlassen der Fall-Liste den Auswahlmodus nicht offen lassen + if (!cases && this.caseMode !== null) this.endCaseMode(); + document.getElementById('pane-cases').hidden = !cases; + document.getElementById('pane-sources').hidden = cases; + const tc = document.getElementById('lt-cases'); + const ts = document.getElementById('lt-sources'); + tc.classList.toggle('active', cases); + ts.classList.toggle('active', !cases); + tc.setAttribute('aria-selected', String(cases)); + ts.setAttribute('aria-selected', String(!cases)); + }, + + // === Fall-Liste (früher das Dropdown in der Kopfzeile) === + async loadIncidents() { + try { + this.incidents = await API.listIncidents() || []; + } catch (e) { + UI.showToast('Lagen konnten nicht geladen werden', 'error'); + this.incidents = []; + } + this.renderCases(); + }, + + filterCases(q) { + this.caseFilter = (q || '').trim().toLowerCase(); + this.renderCases(); + }, + + toggleArchive() { + this._archiveOpen = !this._archiveOpen; + this.renderCases(); + }, + + // Fälle, die der aktuelle Filter zeigt (Archiv nur, wenn aufgeklappt) - genau + // diese Menge trifft "Alle auswählen". + _visibleCases() { + const q = this.caseFilter; + const match = (i) => !q || (i.title || '').toLowerCase().includes(q); + return this.incidents.filter(i => match(i) && (i.status === 'active' || this._archiveOpen)); + }, + + renderCases() { + const list = document.getElementById('cases-list'); + if (!list) return; + const q = this.caseFilter; + const match = (i) => !q || (i.title || '').toLowerCase().includes(q); + const all = this.incidents.filter(match); + const cur = this.incident ? String(this.incident.id) : null; + + const groups = [ + { key: 'live', label: 'Live-Monitoring', items: all.filter(i => i.type !== 'research' && i.status === 'active') }, + { key: 'research', label: 'Recherchen', items: all.filter(i => i.type === 'research' && i.status === 'active') }, + ]; + const archived = all.filter(i => i.status !== 'active'); + + // Haken und Kontextmenue schließen sich aus: im Auswahlmodus häkelt man, + // sonst öffnet ein Klick den Fall. + const sel = this.caseMode !== null; + const item = (i) => ` +
+ ${sel ? `` : ''} + + ${sel ? '' : ``} +
`; + + let html = ''; + groups.forEach(g => { + if (!g.items.length) return; + html += `
${g.label} ${g.items.length}
`; + html += g.items.map(item).join(''); + }); + if (archived.length) { + html += ``; + if (this._archiveOpen) html += archived.map(item).join(''); + } + list.innerHTML = html; // ohne Treffer bleibt die Liste bewusst leer (kein Hinweistext) + + const cnt = document.getElementById('cases-count'); + if (cnt) cnt.textContent = this.incidents.filter(i => i.status === 'active').length || ''; + this._renderBulkBar(); + }, + + /** + * Rueckfrage im Studio-Design statt Browser-confirm(). Gibt ein Promise. + * (Das native confirm sieht nach Browser-Warnung aus und blockiert die Seite.) + */ + _confirmResolve: null, + askConfirm({ title, body, okLabel = 'Löschen', danger = true }) { + return new Promise((resolve) => { + this._confirmResolve = resolve; + document.getElementById('confirm-title').textContent = title; + document.getElementById('confirm-body').innerHTML = body; + const ok = document.getElementById('confirm-ok'); + ok.textContent = okLabel; + ok.className = 'btn ' + (danger ? 'btn-danger' : 'btn-primary'); + document.getElementById('modal-confirm').classList.add('active'); + setTimeout(() => document.getElementById('confirm-cancel').focus(), 50); + }); + }, + _confirmClose(answer) { + document.getElementById('modal-confirm').classList.remove('active'); + const r = this._confirmResolve; + this._confirmResolve = null; + if (r) r(answer); + }, + + /* === Startpanel: der frisch angelegte Fall fragt aktiv nach === + Früher stand der Nutzer nach dem Anlegen vor einer leeren Oberflaeche und + musste die Pipeline kennen, um irgendetwas auszuloesen. Jetzt legt das Studio + die Wege offen hin - "später entscheiden" ist einer davon, aber eine bewusste + Wahl. Ein Fall, für den schon einmal gewählt wurde, fragt nicht erneut. */ + _renderStartPanel() { + const panel = document.getElementById('start-panel'); + if (!panel) return; + const inc = this.incident; + const leer = inc && !(this.articles || []).length && !((inc.summary || '').trim()); + const schonGewaehlt = inc && localStorage.getItem('studio_start_' + inc.id); + const zeigen = !!leer && !this._runningStage && !schonGewaehlt; + panel.hidden = !zeigen; + + // "Kompletter Lauf" rechts und die Startfrage sind dieselbe Aktion - nie beide + // gleichzeitig zeigen. Wird die Frage nicht (mehr) gestellt, ist der Knopf wieder + // da; sonst stünde man nach einem ergebnislosen Lauf ohne Startmoeglichkeit da. + const runAll = document.querySelector('.run-all'); + if (runAll) runAll.hidden = zeigen; + + if (!zeigen) return; + + // Texte an den Lage-Typ anpassen: eine Recherche läuft beim ersten Mal + // mit drei Durchläufen und dauert entsprechend laenger. + const research = inc.type === 'research'; + const full = document.getElementById('sp-full-desc'); + if (full) { + full.textContent = research + ? 'Tiefenrecherche in drei Durchläufen (Breite Erfassung, Vertiefung, Konsolidierung), danach Analyse und Faktencheck. Dauert deutlich länger als ein einzelner Lauf.' + : 'Sammeln, Analyse, Faktencheck und Karte nacheinander. Am Ende steht ein fertiges Lagebild.'; + } + const auto = document.getElementById('sp-auto-desc'); + if (auto) { + auto.textContent = inc.refresh_mode === 'auto' + ? 'Ist für diesen Fall bereits eingeschaltet - der erste Lauf startet von selbst innerhalb einer Minute.' + : `Der Fall läuft ab jetzt selbstständig (alle ${inc.refresh_interval || 15} Minuten). Der erste Lauf beginnt sofort.`; + } + this._checkSearchStatus(); + }, + + // Ehrlich ansagen, wenn ein Lauf gerade kaum etwas bringen kann + async _checkSearchStatus() { + const box = document.getElementById('sp-warn'); + const txt = document.getElementById('sp-warn-text'); + if (!box) return; + let st = null; + try { st = await API.getSearchStatus(); } catch (e) { return; } + const blind = !st || !st.reachable || !st.search_enabled; + box.hidden = !blind; + if (blind && txt) { + txt.textContent = (!st || !st.reachable) + ? 'Die Websuche antwortet gerade nicht (Suchdienst nicht erreichbar). Ein Lauf bringt derzeit nur Treffer aus den RSS-Feeds.' + : 'Die Websuche ist abgeschaltet, weil kein mobiler Ausgang steht. Ein Lauf bringt derzeit nur Treffer aus den RSS-Feeds.'; + } + }, + + async startChoice(kind) { + const panel = document.getElementById('start-panel'); + const inc = this.incident; + if (!inc) return; + + // Eine Wahl -> dieser Fall fragt nicht noch einmal, und der Startknopf rechts + // kommt zurück (beide sind dieselbe Aktion). + localStorage.setItem('studio_start_' + inc.id, kind); + panel.hidden = true; + const runAll = document.querySelector('.run-all'); + if (runAll) runAll.hidden = false; + + if (kind === 'ingest') { + this.leftTab('sources'); + const ip = document.getElementById('ingest-panel'); + if (ip && ip.hidden) this.toggleIngest(); + document.getElementById('ingest-add-btn').focus(); + return; + } + if (kind === 'auto') { + try { + await API.updateIncident(inc.id, { refresh_mode: 'auto' }); + inc.refresh_mode = 'auto'; + await this.loadIncidents(); + UI.showToast('Automatische Aktualisierung eingeschaltet - der erste Lauf startet gleich.', 'success'); + } catch (e) { + UI.showToast((e && e.message) || 'Umstellen fehlgeschlagen', 'error'); + localStorage.removeItem('studio_start_' + inc.id); + panel.hidden = false; + } + return; + } + // 'full' (kompletter Lauf) oder 'collect' (nur sammeln) + this.runStage(kind); + }, + + // === Auswahlmodus + Massenaktionen === + // Die Haken sind normalerweise WEG. Erst der Knopf "Archivieren" bzw. "Löschen" + // unten schaltet den Auswahlmodus ein und blendet sie ein. + startCaseMode(mode) { + this.caseMode = mode; + this.caseSel.clear(); + this.renderCases(); + }, + endCaseMode() { + this.caseMode = null; + this.caseSel.clear(); + const sa = document.getElementById('case-select-all'); + if (sa) sa.checked = false; + this.renderCases(); + }, + + toggleCaseSel(id, on) { + if (on) this.caseSel.add(id); else this.caseSel.delete(id); + this.renderCases(); + }, + + selectAllCases(on) { + const vis = this._visibleCases(); + if (on) vis.forEach(i => this.caseSel.add(i.id)); + else vis.forEach(i => this.caseSel.delete(i.id)); + this.renderCases(); + }, + + _renderBulkBar() { + const mode = this.caseMode; + const n = this.caseSel.size; + + // Standardleiste (zwei Startknoepfe) vs. Auswahlmodus (bestätigen/abbrechen) + const tools = document.getElementById('case-tools'); + const bar = document.getElementById('bulk-bar'); + const row = document.getElementById('case-select-row'); + if (tools) tools.hidden = mode !== null; + if (bar) bar.hidden = mode === null; + if (row) row.hidden = mode === null; + if (mode === null) return; + + const cnt = document.getElementById('bulk-count'); + if (cnt) { + cnt.textContent = n === 0 + ? (mode === 'delete' ? 'Fälle zum Löschen anhaken' : 'Fälle zum Archivieren anhaken') + : (n === 1 ? '1 Fall ausgewählt' : `${n} Fälle ausgewählt`); + } + + // Nur anbieten, was zum Modus UND zur Auswahl passt + const sel = this.incidents.filter(i => this.caseSel.has(i.id)); + const anyActive = sel.some(i => i.status === 'active'); + const anyArchived = sel.some(i => i.status !== 'active'); + const ba = document.getElementById('bulk-archive'); + const bv = document.getElementById('bulk-activate'); + const bd = document.getElementById('bulk-delete'); + if (ba) { ba.hidden = mode !== 'archive' || !anyActive; ba.textContent = `Archivieren (${sel.filter(i => i.status === 'active').length})`; } + if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; bv.textContent = `Aktivieren (${sel.filter(i => i.status !== 'active').length})`; } + if (bd) { bd.hidden = mode !== 'delete' || n === 0; bd.textContent = `Löschen (${n})`; } + + // "Alle"-Haken spiegelt, ob die sichtbare Menge komplett gewählt ist + const vis = this._visibleCases(); + const sa = document.getElementById('case-select-all'); + if (sa) sa.checked = vis.length > 0 && vis.every(i => this.caseSel.has(i.id)); + + const hint = document.getElementById('case-sel-hint'); + if (hint) hint.textContent = n ? `${n} ausgewählt` : ''; + }, + + // Massenaktionen laufen sequentiell über die normalen Endpunkte (mit deren + // Rechte-Pruefung). Fehlschlaege werden gezählt statt still verschluckt. + async _bulkRun(ids, fn, verb) { + let ok = 0; + const failed = []; + for (const id of ids) { + try { await fn(id); ok++; } + catch (e) { failed.push(id); } + } + this.caseSel.clear(); + this.caseMode = null; // Auswahlmodus verlassen -> Haken verschwinden wieder + await this.loadIncidents(); + if (failed.length) { + UI.showToast(`${ok} ${verb}, ${failed.length} fehlgeschlagen (IDs ${failed.join(', ')})`, 'error'); + } else { + UI.showToast(`${ok} ${ok === 1 ? 'Fall' : 'Fälle'} ${verb}`, 'success'); + } + }, + + async bulkStatus(status) { + const ids = this.incidents + .filter(i => this.caseSel.has(i.id) && (status === 'archived' ? i.status === 'active' : i.status !== 'active')) + .map(i => i.id); + if (!ids.length) return; + const verb = status === 'archived' ? 'archiviert' : 'wieder aktiviert'; + await this._bulkRun(ids, (id) => API.updateIncident(id, { status }), verb); + }, + + async bulkDelete() { + const sel = this.incidents.filter(i => this.caseSel.has(i.id)); + if (!sel.length) return; + const arts = sel.reduce((n, i) => n + (i.article_count || 0), 0); + const liste = sel.slice(0, 8).map(i => `
  • ${UI.escape(i.title)}
  • `).join('') + + (sel.length > 8 ? `
  • … und ${sel.length - 8} weitere
  • ` : ''); + const ok = await this.askConfirm({ + title: `${sel.length} ${sel.length === 1 ? 'Fall' : 'Fälle'} endgültig löschen?`, + okLabel: `Löschen (${sel.length})`, + body: ` +

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

    +

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

    `, + }); + if (!ok) return; + + const ids = sel.map(i => i.id); + const offen = this.incident && ids.includes(this.incident.id); + await this._bulkRun(ids, (id) => API.deleteIncident(id), 'gelöscht'); + if (offen) { + // Der offene Fall war dabei -> zurück in die leere Ansicht + this.incident = null; + localStorage.removeItem('studio_incident'); + this._showEmpty(true); + this.leftTab('cases'); + document.getElementById('incident-title').textContent = ''; + document.getElementById('type-badge').style.display = 'none'; + } + }, + + // Kontextmenue je Fall: archivieren / reaktivieren / löschen + caseMenu(ev, id) { + ev.stopPropagation(); + this._closeCaseMenu(); + const inc = this.incidents.find(i => String(i.id) === String(id)); + if (!inc) return; + const archived = inc.status !== 'active'; + const m = document.createElement('div'); + m.className = 'case-menu'; + m.id = 'case-menu'; + m.innerHTML = ` + + `; + document.body.appendChild(m); + const r = ev.currentTarget.getBoundingClientRect(); + m.style.top = `${Math.min(r.bottom + 4, window.innerHeight - m.offsetHeight - 8)}px`; + m.style.left = `${Math.min(r.left, window.innerWidth - m.offsetWidth - 8)}px`; + this._caseMenuCloser = () => this._closeCaseMenu(); + setTimeout(() => document.addEventListener('click', this._caseMenuCloser, { once: true }), 0); + }, + _closeCaseMenu() { + const m = document.getElementById('case-menu'); + if (m) m.remove(); + if (this._caseMenuCloser) { + document.removeEventListener('click', this._caseMenuCloser); + this._caseMenuCloser = null; + } + }, + + async setCaseStatus(id, status) { + this._closeCaseMenu(); + try { + await API.updateIncident(id, { status }); + await this.loadIncidents(); + UI.showToast(status === 'archived' ? 'Fall archiviert' : 'Fall wieder aktiv', 'info'); + } catch (e) { + UI.showToast((e && e.message) || 'Änderung fehlgeschlagen', 'error'); + } + }, + + async deleteCase(id) { + this._closeCaseMenu(); + const inc = this.incidents.find(i => String(i.id) === String(id)); + const name = inc ? inc.title : 'dieser Fall'; + const arts = inc ? (inc.article_count || 0) : 0; + const ok = await this.askConfirm({ + title: 'Fall endgültig löschen?', + okLabel: 'Löschen', + body: ` +

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

    +

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

    `, + }); + if (!ok) return; + try { + await API.deleteIncident(id); + if (this.incident && String(this.incident.id) === String(id)) { + this.incident = null; + localStorage.removeItem('studio_incident'); + this._showEmpty(true); + this.leftTab('cases'); + document.getElementById('incident-title').textContent = ''; + document.getElementById('type-badge').style.display = 'none'; + } + await this.loadIncidents(); + UI.showToast('Fall gelöscht', 'info'); + } catch (e) { + UI.showToast((e && e.message) || 'Löschen fehlgeschlagen', 'error'); + } + }, + + // === Auswahl + Detail laden === + async selectIncident(id) { + id = parseInt(id); + if (!id) return; + App.currentIncidentId = id; + localStorage.setItem('studio_incident', id); + this._showEmpty(false); + this.selected = null; + this.convId = null; + const si = document.getElementById('src-search-input'); + if (si) si.value = ''; + // Fall gewählt -> es geht mit den Quellen weiter + this.leftTab('sources'); + await this.loadDetail(id); + this.renderCases(); // aktiven Fall in der Liste hervorheben + }, + + async loadDetail(id) { + let inc, arts, fcs, locs, srcMeta, citeSrc, fresh; + try { + [inc, arts, fcs, locs, srcMeta, citeSrc, fresh] = await Promise.all([ + API.getIncident(id), + API.getArticles(id, { limit: 500 }).catch(() => ({ articles: [] })), + API.getFactChecks(id).catch(() => []), + API.getLocations(id).catch(() => []), + this.sourcesMeta.length ? Promise.resolve(this.sourcesMeta) : API.listSources().catch(() => []), + API.getIncidentSources(id).catch(() => ({ sources: [] })), + API.getFreshness(id).catch(() => null), + ]); + } catch (e) { + UI.showToast('Lage-Details konnten nicht geladen werden', 'error'); + return; + } + this.fresh = fresh; // Datenstand je Artefakt (Grundlage der Veraltet-Anzeige) + this.incident = inc; + this.articles = Array.isArray(arts) ? arts : (arts.articles || []); + this.sourcesMeta = Array.isArray(srcMeta) ? srcMeta : []; + // /locations antwortet mit {locations, category_labels}, nicht mit einem Array. + // Ungeprüft weitergereicht wirft UI.renderMap (locations.reduce) und bricht + // loadDetail ab. Gleiche Normalisierung wie im Dashboard (app.js). + const locList = Array.isArray(locs) ? locs : ((locs && locs.locations) || []); + const catLabels = (locs && !Array.isArray(locs) && locs.category_labels) || null; + // Zitat-Quellen (getIncident liefert sources_json bewusst nicht mit) + this.citeSources = (citeSrc && citeSrc.sources) || []; + + this.renderHeader(inc); + this.renderSources(); + // Quellen-Ingest: Zustand je Fall zurücksetzen + laufende/fertige Uploads laden + this._stopUploadPoll(); + this._seenDone = new Set(); + this.uploads = []; + const ip = document.getElementById('ingest-panel'); if (ip) ip.hidden = true; + this.loadUploads(); + this.renderArtifacts(inc, fcs, locList, catLabels); + this._stagesIdle(); // Karten freischalten + Datenstand anzeigen + this._resumeStage(); // läuft gerade ein Baustein? -> Karte zurück auf "läuft" + this._renderStartPanel(); // leerer Fall? -> aktiv nach dem nächsten Schritt fragen + this.renderChatIntro(inc); + // Snapshots lazy nachladen + this._snapLoaded = false; + // Studio-Tabs zurücksetzen und Lagebild als Standard-Tab öffnen + this._initTabs(); + }, + + _sources(inc) { + // Zitat-Quellen aus /incidents/{id}/sources (getIncident liefert sie nicht mit) + return this.citeSources || []; + }, + _isResearch() { return this.incident && this.incident.type === 'research'; }, + + renderHeader(inc) { + const badge = document.getElementById('type-badge'); + const research = inc.type === 'research'; + // Der Titel stand früher im Dropdown - ohne ihn wüsste man nicht, worin man arbeitet + const t = document.getElementById('incident-title'); + if (t) { + t.textContent = inc.title || ''; + t.title = inc.title || ''; + } + badge.textContent = research ? 'Analyse' : 'Live'; + badge.className = 'studio-type-badge' + (research ? ' type-research' : ''); + badge.style.display = ''; + document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild'; + document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen'; + }, + + // === Spalte 1: Quellen === + _norm(s) { return (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim(); }, + + _domainOf(url) { + try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); } + catch (e) { return ''; } + }, + _reg(dom) { return (dom || '').split('.').slice(-2).join('.'); }, + + _feedFor(sourceName, sampleUrl) { + // 1. Domain-Match (zuverlässig) — exakter Host, dann registrierbare Domain. + // KEIN Fuzzy-Namensmatch: "Bundestag" würde sonst fälschlich die + // Telegram-Quelle "AfD-Fraktion im Bundestag" (niedrig) treffen. + const dom = this._domainOf(sampleUrl); + if (dom) { + let f = this.sourcesMeta.find(s => s.domain && s.domain.toLowerCase() === dom); + if (f) return f; + const reg = this._reg(dom); + f = this.sourcesMeta.find(s => s.domain && this._reg(s.domain.toLowerCase()) === reg); + if (f) return f; + } + // 2. Exakter (normalisierter) Namensmatch als Fallback — kein Teilstring. + const n = this._norm(sourceName); + if (!n) return null; + return this.sourcesMeta.find(s => this._norm(s.name) === n) || null; + }, + + renderSources() { + const list = document.getElementById('sources-list'); + // Fertige Uploads je Artikel -> Kategorie + Aktionen + const upByArt = {}; + (this.uploads || []).forEach(u => { if (u.status === 'done' && u.article_id) upByArt[u.article_id] = u; }); + + // Artikel nach Quelle gruppieren; Quelle einer Kategorie zuordnen + // (reguläre/RSS-Quellen -> "webseiten", hinzugefügte Materialien -> ihre Kategorie) + const groups = {}; + this.articles.forEach(a => { + const name = a.source || 'Unbekannt'; + const up = upByArt[a.id]; + if (!groups[name]) groups[name] = { name, arts: [], langs: new Set(), cat: 'webseiten' }; + groups[name].arts.push(a); + groups[name].langs.add((a.language || 'de').toUpperCase()); + if (up) groups[name].cat = up.category || 'dokumente'; + }); + const arr = Object.values(groups); + + // Im Reiter ist nur Platz für die Zahl; das Ausführliche steckt im Tooltip + const sc = document.getElementById('sources-count'); + sc.textContent = arr.length || ''; + sc.title = `${this.articles.length} Artikel aus ${arr.length} Quellen`; + + if (!arr.length) { + list.innerHTML = '
    Noch keine Artikel. Starte eine Aktualisierung.
    '; + return; + } + + // nach Kategorie gliedern (feste Reihenfolge), innerhalb nach Artikelzahl + const order = ['webseiten', 'dokumente', 'bilder', 'sprachnachrichten']; + const byCat = {}; + arr.forEach(g => { (byCat[g.cat] = byCat[g.cat] || []).push(g); }); + let html = ''; + order.filter(c => byCat[c]).forEach(cat => { + const meta = this._ingCat[cat] || { label: cat, icon: '' }; + const gs = byCat[cat].sort((a, b) => b.arts.length - a.arts.length); + const nArts = gs.reduce((n, g) => n + g.arts.length, 0); + const collapsed = this._collapsedCats && this._collapsedCats.has(cat); + html += `
    +
    + + ${meta.icon}${meta.label}${nArts} +
    `; + html += gs.map(g => this._renderSourceGroup(g, upByArt)).join(''); + html += `
    `; + }); + list.innerHTML = html; + }, + _renderSourceGroup(g, upByArt) { + const feed = this._feedFor(g.name, g.arts[0] && g.arts[0].source_url); + const badges = feed ? UI._renderClassificationBadges(feed) : ''; + const nameEsc = UI.escape(g.name); + const arts = g.arts + .slice() + .sort((a, b) => this._ts(b) - this._ts(a)) + .slice(0, 30) + .map(a => { + const t = this._fmtDate(a); + const h = UI.escape(a.headline_de || a.headline || a.title || 'Ohne Titel'); + const up = upByArt[a.id]; + if (up && up.category !== 'webseiten') { + // Datei-Quelle (Dokument/Bild/Audio): Aktionen statt Link + return `
    +
    ${h}
    +
    ${this._uploadActions(up)} + +
    +
    +
    `; + } + return a.source_url + ? `${h}` + : `
    ${h}
    `; + }).join(''); + return `
    +
    + + ${nameEsc} + ${badges} + ${[...g.langs].join('/')} + ${g.arts.length} +
    +
    ${arts}
    +
    `; + }, + + toggleSrcOpen(nameEl) { + const item = nameEl.closest('.src-item'); + if (item) item.classList.toggle('open'); + }, + toggleCat(cat) { + if (!this._collapsedCats) this._collapsedCats = new Set(); + const el = document.querySelector(`.src-cat[data-cat="${cat}"]`); + if (this._collapsedCats.has(cat)) { this._collapsedCats.delete(cat); if (el) el.classList.remove('collapsed'); } + else { this._collapsedCats.add(cat); if (el) el.classList.add('collapsed'); } + }, + toggleSource(name, on) { + if (this.selected === null) { + // bisher alle aktiv -> Set aus allen Namen bilden + this.selected = new Set(Object.values(this.articles.reduce((m, a) => { + m[a.source || 'Unbekannt'] = a.source || 'Unbekannt'; return m; + }, {}))); + } + if (on) this.selected.add(name); else this.selected.delete(name); + const item = document.querySelector(`.src-item[data-source="${CSS.escape(name)}"]`); + if (item) item.classList.toggle('deselected', !on); + // "Alle"-Checkbox synchronisieren + const total = document.querySelectorAll('.src-item').length; + document.getElementById('src-select-all').checked = this.selected.size >= total; + }, + toggleAllSources(on) { + document.querySelectorAll('.src-item').forEach(item => { + item.classList.toggle('deselected', !on); + const cb = item.querySelector('.src-check'); + if (cb) cb.checked = on; + }); + if (on) { this.selected = null; } + else { this.selected = new Set(); } + }, + _activeSourceFilter() { + if (this.selected === null) return null; // alle + return [...this.selected]; + }, + + // Live-Filter der Quellenliste (Name + Artikel-Ueberschriften) + filterSources(q) { + const term = (q || '').trim().toLowerCase(); + document.querySelectorAll('#sources-list .src-item').forEach(item => { + const name = (item.dataset.source || '').toLowerCase(); + let match = !term || name.includes(term); + if (!match) { + const arts = item.querySelectorAll('.src-article'); + for (const a of arts) { + if (a.textContent.toLowerCase().includes(term)) { match = true; break; } + } + } + item.classList.toggle('filtered-out', !match); + }); + }, + + // === Quellen-Ingest (Upload/URL -> Artikel) === + _ingCat: { + webseiten: { label: 'Webseiten', icon: '' }, + dokumente: { label: 'Dokumente', icon: '' }, + bilder: { label: 'Bilder', icon: '' }, + sprachnachrichten:{ label: 'Sprachnachrichten', icon: '' }, + }, + toggleIngest() { + const p = document.getElementById('ingest-panel'); + if (p) p.hidden = !p.hidden; + }, + dzOver(e) { e.preventDefault(); document.getElementById('dropzone').classList.add('drag'); }, + dzLeave(e) { e.preventDefault(); document.getElementById('dropzone').classList.remove('drag'); }, + dzDrop(e) { + e.preventDefault(); + document.getElementById('dropzone').classList.remove('drag'); + const files = e.dataTransfer && e.dataTransfer.files; + if (files && files.length) this.dzFiles(files); + }, + dzFiles(fileList) { + const files = Array.from(fileList || []); + if (files.length) this._submitUpload(files, null); + }, + urlKey(e) { if (e.key === 'Enter') { e.preventDefault(); this.addUrl(); } }, + addUrl() { + const inp = document.getElementById('ingest-url'); + const u = (inp.value || '').trim(); + if (!u) return; + if (!/^https?:\/\//i.test(u)) { UI.showToast('URL muss mit http:// oder https:// beginnen', 'error'); return; } + inp.value = ''; + this._submitUpload([], u); + }, + async _submitUpload(files, url) { + if (!this.incident) return; + try { + await API.createUploads(this.incident.id, { files, url }); + await this.loadUploads(); + this._startUploadPoll(); + } catch (e) { + UI.showToast((e && e.message) || 'Hinzufügen fehlgeschlagen', 'error'); + } + }, + async loadUploads() { + if (!this.incident) return; + let res; + try { res = await API.listUploads(this.incident.id); } + catch (e) { return; } + const prevDone = new Set((this.uploads || []).filter(u => u.status === 'done').map(u => u.id)); + this.uploads = (res && res.uploads) || []; + this.renderUploads(); + // Frisch fertige Jobs -> neue Artikel in die Quellenliste übernehmen + const freshlyDone = this.uploads.some(u => u.status === 'done' && !prevDone.has(u.id) && !this._seenDone.has(u.id)); + this.uploads.forEach(u => { if (u.status === 'done') this._seenDone.add(u.id); }); + if (freshlyDone) this._refreshCorpus(); + const active = this.uploads.some(u => u.status === 'queued' || u.status === 'processing'); + if (active) this._startUploadPoll(); else this._stopUploadPoll(); + }, + _startUploadPoll() { + if (this._uploadPoll) return; + this._uploadPoll = setInterval(() => this.loadUploads(), 1800); + }, + _stopUploadPoll() { + if (this._uploadPoll) { clearInterval(this._uploadPoll); this._uploadPoll = null; } + }, + async _refreshCorpus() { + try { + const arts = await API.getArticles(this.incident.id, { limit: 500 }).catch(() => ({ articles: [] })); + this.articles = Array.isArray(arts) ? arts : (arts.articles || []); + this.renderSources(); + } catch (e) { /* nicht kritisch */ } + }, + renderUploads() { + const host = document.getElementById('ingest-jobs'); + if (!host) return; + // Nur laufende/fehlerhafte Jobs hier; fertige erscheinen in der Quellenliste. + const ups = (this.uploads || []).filter(u => u.status === 'queued' || u.status === 'processing' || u.status === 'error'); + if (!ups.length) { host.innerHTML = ''; return; } + // nach Kategorie gruppieren (feste Reihenfolge) + const order = ['webseiten', 'dokumente', 'bilder', 'sprachnachrichten']; + const groups = {}; + ups.forEach(u => { (groups[u.category] = groups[u.category] || []).push(u); }); + let html = ''; + order.filter(c => groups[c]).forEach(cat => { + const meta = this._ingCat[cat] || { label: cat, icon: '' }; + html += `
    ${meta.icon}${meta.label}${groups[cat].length}
    `; + groups[cat].forEach(u => { html += this._renderUploadItem(u); }); + html += `
    `; + }); + host.innerHTML = html; + }, + _renderUploadItem(u) { + const name = UI.escape(u.filename || u.source_url || 'Quelle'); + const busy = (u.status === 'queued' || u.status === 'processing'); + let status = ''; + if (busy) { + const pct = Math.max(3, u.progress || 0); + status = `
    +
    ${UI.escape(u.stage || 'In Arbeit')} · ${pct}%
    `; + } else if (u.status === 'error') { + status = `
    Fehler: ${UI.escape(u.error || 'unbekannt')}
    `; + } else if (u.status === 'done') { + status = `
    ${this._uploadActions(u)}
    `; + } + return `
    +
    + ${name} + +
    + ${status} +
    +
    `; + }, + _uploadActions(u) { + const aid = u.article_id || 0; + const btns = []; + if (u.category === 'sprachnachrichten') { + btns.push(``); + btns.push(``); + } else if (u.category === 'bilder') { + btns.push(``); + btns.push(``); + } else if (u.category === 'webseiten') { + btns.push(``); + if (u.source_url) btns.push(`Link öffnen`); + } else { + btns.push(``); + if (u.has_file) btns.push(``); + } + return btns.join(''); + }, + toggleText(uid, articleId) { + const host = document.getElementById('ju-media-' + uid); + if (!host) return; + if (host.dataset.open === 'text') { host.innerHTML = ''; host.dataset.open = ''; return; } + const art = (this.articles || []).find(a => a.id === articleId); + const txt = art ? (art.content_de || art.content_original || '') : ''; + host.innerHTML = `
    ${txt ? UI.escape(txt).slice(0, 8000).replace(/\n/g, '
    ') : 'Text wird vorbereitet …'}
    `; + host.dataset.open = 'text'; + if (!art) this._refreshCorpus(); + }, + async playAudio(uid) { + const host = document.getElementById('ju-media-' + uid); + if (!host) return; + if (host.dataset.open === 'audio') { host.innerHTML = ''; host.dataset.open = ''; return; } + host.innerHTML = 'Lädt …'; + host.dataset.open = 'audio'; + try { + const url = await API.fetchUploadBlobUrl(this.incident.id, uid); + host.innerHTML = ``; + } catch (e) { + host.innerHTML = `${UI.escape(e.message || 'Audio-Fehler')}`; + } + }, + async openOriginal(uid) { + try { + const url = await API.fetchUploadBlobUrl(this.incident.id, uid); + window.open(url, '_blank', 'noopener'); + } catch (e) { UI.showToast(e.message || 'Datei-Fehler', 'error'); } + }, + async deleteUploadItem(uid) { + const ok = await this.askConfirm({ + title: 'Quelle entfernen?', + okLabel: 'Entfernen', + body: '

    Die hochgeladene Datei und der daraus erzeugte Text werden entfernt.

    ', + }); + if (!ok) return; + try { + await API.deleteUpload(this.incident.id, uid); + this._seenDone.delete(uid); + await this.loadUploads(); + this._refreshCorpus(); + } catch (e) { UI.showToast(e.message || 'Löschen fehlgeschlagen', 'error'); } + }, + + // === Spalte 3: Artefakte === + renderArtifacts(inc, fcs, locs, catLabels) { + const sources = this._sources(inc); + + // Lagebild / Recherchebericht + Neueste Entwicklungen / Zusammenfassung + // Bei Recherche die Zusammenfassung/Ueberblick aus dem Bericht herauslösen, + // damit sie nicht doppelt (auch im eigenen Zusammenfassung-Tab) erscheint. + const summaryEl = document.getElementById('art-summary-body'); + const latestEl = document.getElementById('art-latest-body'); + if (inc.type === 'research') { + const ex = UI.extractZusammenfassung(inc.summary || ''); + const reportText = ex.zusammenfassung ? ex.remaining : (inc.summary || ''); + summaryEl.innerHTML = UI.renderSummary(reportText, sources, inc.type); + latestEl.innerHTML = ex.zusammenfassung + ? UI.renderZusammenfassung(ex.zusammenfassung, sources) + : 'Keine separate Zusammenfassung.'; + } else { + summaryEl.innerHTML = UI.renderSummary(inc.summary || '', sources, inc.type); + latestEl.innerHTML = UI.renderLatestDevelopments(inc.latest_developments || '', sources); + } + + // Faktencheck + const fcBody = document.getElementById('art-fc-body'); + App._fcHidden = new Set(); + if (fcs && fcs.length) { + document.getElementById('art-fc-meta').textContent = fcs.length + ' geprüft'; + const filters = UI.renderFactCheckFilters(fcs); + fcBody.innerHTML = (filters ? `
    ${filters}
    ` : '') + + fcs.map(fc => UI.renderFactCheck(fc)).join(''); + } else { + document.getElementById('art-fc-meta').textContent = ''; + fcBody.innerHTML = 'Noch keine Faktenchecks.'; + } + // Faktencheck-Historie (frühere, komplett-neu ersetzte Läufe) + fcBody.insertAdjacentHTML('beforeend', + `
    + Frühere Faktencheck-Läufe +
    Beim Öffnen geladen.
    +
    `); + + // Karte (Kategorie-Legende kommt aus /locations mit) + UI.renderMap(locs || [], catLabels || null); + + // Ereignis-Timeline: lazy beim Öffnen des Tabs laden + this._tlLoaded = false; + document.getElementById('art-tl-meta').textContent = ''; + document.getElementById('art-tl-body').innerHTML = 'Wird beim Öffnen geladen.'; + + // Snapshots-Meta zurücksetzen + document.getElementById('art-snap-meta').textContent = ''; + document.getElementById('art-snap-body').innerHTML = 'Wird beim Öffnen geladen.'; + }, + + // Aktivitaets-/Ereignis-Timeline (lazy beim Öffnen des Tabs) + async loadTimeline(force) { + if (this._tlLoaded && !force) return; + this._tlLoaded = true; + const el = document.getElementById('art-tl-body'); + if (!this.incident) return; + el.innerHTML = 'Lade Ereignisse …'; + let events = []; + try { + const res = await API.getEvents(this.incident.id); + events = (res && res.events) || []; + } catch (e) { + el.innerHTML = 'Ereignisse konnten nicht geladen werden.'; + return; + } + document.getElementById('art-tl-meta').textContent = events.length + ' Ereignisse'; + if (!events.length) { el.innerHTML = 'Noch keine Ereignisse.'; return; } + + // nach Typ in Untersektionen gruppieren (Reihenfolge in der Gruppe: neueste zuerst) + const groups = {}; + events.forEach(ev => { (groups[ev.type] = groups[ev.type] || []).push(ev); }); + const sections = this._evSections.slice(); + Object.keys(groups).forEach(k => { + if (!sections.some(s => s.key === k)) { + sections.push({ key: k, label: (this._evMeta[k] || {}).label || 'Sonstige' }); + } + }); + + el.innerHTML = sections + .filter(s => groups[s.key] && groups[s.key].length) + .map(s => { + const icon = (this._evMeta[s.key] || {}).icon || ''; + const items = groups[s.key].map(ev => this._renderEvent(ev)).join(''); + return `
    + + ${icon} + ${UI.escape(s.label)} + ${groups[s.key].length} + +
    ${items}
    +
    `; + }).join(''); + }, + + _fmtTs(ts) { + const d = parseUTC(ts); + return d ? this._fmt(d, true) : ''; + }, + + _renderEvent(ev) { + const time = this._fmtTs(ev.ts); + let body; + if (ev.type === 'chat_qa') { + const d = ev.detail || ''; + const marker = '— Antwort —'; + const mi = d.indexOf(marker); + const q = (mi >= 0 ? d.slice(0, mi) : (ev.title || '')).trim(); + const a = mi >= 0 ? d.slice(mi + marker.length).trim() : ''; + body = `
    ${UI.escape(q)}
    ` + + (a ? `
    Antwort anzeigen
    ${this._renderReply(a, [])}
    ` : ''); + } else if (ev.type === 'article_ingest') { + const t = ev.url + ? `${UI.escape(ev.title || '')}` + : UI.escape(ev.title || ''); + body = `
    ${t}
    ` + (ev.source ? `
    ${UI.escape(ev.source)}
    ` : ''); + } else { + body = `
    ${UI.escape(ev.title || '')}
    ` + + (ev.source ? `
    ${UI.escape(ev.source)}
    ` : ''); + } + return `
    ${time}
    ${body}
    `; + }, + + async loadSnapshots() { + if (this._snapLoaded) return; + this._snapLoaded = true; + const body = document.getElementById('art-snap-body'); + try { + const snaps = await API.getSnapshots(this.incident.id) || []; + const list = Array.isArray(snaps) ? snaps : (snaps.snapshots || []); + document.getElementById('art-snap-meta').textContent = list.length + ' Berichte'; + if (!list.length) { body.innerHTML = 'Noch keine früheren Lageberichte.'; return; } + body.innerHTML = list.map(s => { + const t = s.created_at ? this._fmt(parseUTC(s.created_at)) : ''; + const prev = UI.escape((s.summary_preview || '').trim()); + const meta = `${s.article_count || 0} Artikel · ${s.fact_check_count || 0} Faktenchecks`; + return `
    + ${t}${meta} +
    ${prev}${prev ? ' …' : ''}
    +
    `; + }).join(''); + } catch (e) { + body.innerHTML = 'Konnte Lageberichte nicht laden.'; + } + }, + async onSnapToggle(el, snapId) { + if (!el.open || el.dataset.loaded) return; + el.dataset.loaded = '1'; + const bodyEl = el.querySelector('.hist-body'); + bodyEl.innerHTML = 'Lädt …'; + try { + const snap = await API.getSnapshot(this.incident.id, snapId); + let sources = []; + try { sources = JSON.parse(snap.sources_json || '[]'); } catch (_) {} + bodyEl.innerHTML = UI.renderSummary(snap.summary || '', sources, this.incident && this.incident.type); + } catch (e) { + bodyEl.innerHTML = 'Konnte Bericht nicht laden.'; + } + }, + async onFcHistoryToggle(el) { + if (!el.open || el.dataset.loaded) return; + el.dataset.loaded = '1'; + const body = el.querySelector('.fc-history-body'); + try { + const res = await API.listFactcheckRuns(this.incident.id); + const runs = (res && res.runs) || []; + if (!runs.length) { body.innerHTML = 'Noch keine früheren Läufe.'; return; } + body.innerHTML = runs.map(r => { + const t = r.created_at ? this._fmt(parseUTC(r.created_at)) : ''; + return `
    + ${t}${r.fact_count || 0} Fakten +
    Lädt …
    +
    `; + }).join(''); + } catch (e) { + body.innerHTML = 'Konnte Historie nicht laden.'; + } + }, + async onFcRunToggle(el, runId) { + if (!el.open || el.dataset.loaded) return; + el.dataset.loaded = '1'; + const body = el.querySelector('.hist-body'); + try { + const res = await API.getFactcheckRun(this.incident.id, runId); + const facts = (res && res.facts) || []; + body.innerHTML = facts.length + ? facts.map(fc => UI.renderFactCheck(fc)).join('') + : 'Keine Fakten in diesem Lauf.'; + } catch (e) { + body.innerHTML = 'Konnte Lauf nicht laden.'; + } + }, + + // === Verschiebbare Trennung Tab-Panel <-> Konversation === + _initDivider() { + const divider = document.getElementById('center-divider'); + const center = document.getElementById('studio-center'); + if (!divider || !center) return; + const chat = center.querySelector('.chat-region'); + // gespeicherte Höhe wiederherstellen + const saved = parseInt(localStorage.getItem('studio_chat_h') || '', 10); + if (saved) center.style.setProperty('--chat-h', saved + 'px'); + + let startY = 0, startH = 0; + const onMove = (e) => { + const dy = e.clientY - startY; + let h = startH - dy; // nach oben ziehen => Chat größer + const minChat = 140; + const maxChat = center.clientHeight - 180; // Tab-Panel behält Mindesthoehe + h = Math.max(minChat, Math.min(Math.max(minChat, maxChat), h)); + center.style.setProperty('--chat-h', h + 'px'); + }; + const onUp = (e) => { + divider.classList.remove('dragging'); + document.body.style.userSelect = ''; + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + try { divider.releasePointerCapture(e.pointerId); } catch (_) {} + // Karte neu vermessen, falls Karten-Tab aktiv + if (this.activeTab === 'map' && UI.invalidateMap) UI.invalidateMap(); + const cur = center.style.getPropertyValue('--chat-h'); + if (cur.endsWith('px')) localStorage.setItem('studio_chat_h', parseInt(cur, 10)); + }; + divider.addEventListener('pointerdown', (e) => { + startY = e.clientY; + startH = chat.getBoundingClientRect().height; + divider.classList.add('dragging'); + document.body.style.userSelect = 'none'; + try { divider.setPointerCapture(e.pointerId); } catch (_) {} + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + e.preventDefault(); + }); + }, + + // Tabs per Drag&Drop umsortieren (Delegation auf der statischen Tabbar) + _initTabDnD() { + const bar = document.getElementById('center-tabbar'); + if (!bar) return; + const clearMarkers = () => bar.querySelectorAll('.center-tab').forEach(t => + t.classList.remove('drop-before', 'drop-after')); + + bar.addEventListener('dragstart', (e) => { + const tab = e.target.closest('.center-tab'); + if (!tab) return; + this._dragKey = tab.dataset.art; + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', tab.dataset.art); } catch (_) {} + tab.classList.add('dragging-tab'); + }); + bar.addEventListener('dragend', (e) => { + const tab = e.target.closest('.center-tab'); + if (tab) tab.classList.remove('dragging-tab'); + this._dragKey = null; + clearMarkers(); + }); + bar.addEventListener('dragover', (e) => { + if (!this._dragKey) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + clearMarkers(); + const over = e.target.closest('.center-tab'); + if (over && over.dataset.art !== this._dragKey) { + const r = over.getBoundingClientRect(); + const after = (e.clientX - r.left) > r.width / 2; + over.classList.add(after ? 'drop-after' : 'drop-before'); + } + }); + bar.addEventListener('drop', (e) => { + if (!this._dragKey) return; + e.preventDefault(); + const fromKey = this._dragKey; + const over = e.target.closest('.center-tab'); + let toIndex; + if (over && over.dataset.art !== fromKey) { + const r = over.getBoundingClientRect(); + const after = (e.clientX - r.left) > r.width / 2; + const overIdx = this.openTabs.indexOf(over.dataset.art); + toIndex = after ? overIdx + 1 : overIdx; + } else { + toIndex = this.openTabs.length; + } + const fromIdx = this.openTabs.indexOf(fromKey); + if (fromIdx === -1) return; + this.openTabs.splice(fromIdx, 1); + if (fromIdx < toIndex) toIndex--; + this.openTabs.splice(toIndex, 0, fromKey); + this._dragKey = null; + clearMarkers(); + this._renderTabbar(); // baut Tabs neu, aktiver Zustand bleibt erhalten + }); + }, + + // === Studio-Tabs in der Mitte === + _tabTitle(key) { + const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`); + return el ? el.textContent.trim() : key; + }, + _tabIcon(key) { + const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-icon`); + return el ? el.innerHTML : ''; + }, + + _initTabs() { + this.openTabs = []; + this.activeTab = null; + document.getElementById('studio-center').classList.remove('has-tabs'); + document.getElementById('center-tabbar').innerHTML = ''; + document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => p.classList.remove('active')); + document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => m.classList.remove('open', 'active')); + // Standard: Lagebild/Recherchebericht als erster Tab + this.openTab('summary'); + }, + + _renderTabbar() { + const bar = document.getElementById('center-tabbar'); + bar.innerHTML = this.openTabs.map(key => { + const k = key.replace(/'/g, "\\'"); + return ``; + }).join(''); + document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => { + m.classList.toggle('open', this.openTabs.includes(m.dataset.art)); + }); + }, + + openTab(key) { + if (!this.openTabs.includes(key)) this.openTabs.push(key); + document.getElementById('studio-center').classList.add('has-tabs'); + this._renderTabbar(); + this.activateTab(key); + }, + + activateTab(key) { + this.activeTab = key; + document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => { + p.classList.toggle('active', p.dataset.art === key); + }); + document.querySelectorAll('#center-tabbar .center-tab').forEach(t => { + t.classList.toggle('active', t.dataset.art === key); + }); + document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => { + m.classList.toggle('active', m.dataset.art === key); + }); + // Nebenwirkungen: Karte neu vermessen / Snapshots lazy laden + if (key === 'map') { + setTimeout(() => { + if (UI.retryPendingMap) UI.retryPendingMap(); + if (UI.invalidateMap) UI.invalidateMap(); + }, 60); + } + if (key === 'snapshots') this.loadSnapshots(); + if (key === 'timeline') this.loadTimeline(); + }, + + closeTab(key, ev) { + if (ev) ev.stopPropagation(); + const idx = this.openTabs.indexOf(key); + if (idx === -1) return; + this.openTabs.splice(idx, 1); + const mi = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"]`); + if (mi) mi.classList.remove('open', 'active'); + + if (!this.openTabs.length) { + this.activeTab = null; + document.getElementById('studio-center').classList.remove('has-tabs'); + this._renderTabbar(); + document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => p.classList.remove('active')); + return; + } + // Nachbar-Tab aktivieren, wenn der aktive geschlossen wurde + let nextKey = this.activeTab; + if (this.activeTab === key || !this.openTabs.includes(this.activeTab)) { + nextKey = this.openTabs[Math.min(idx, this.openTabs.length - 1)]; + } + this._renderTabbar(); + this.activateTab(nextKey); + }, + + // === Spalte 2: RAG-Chat === + renderChatIntro(inc) { + const box = document.getElementById('chat-messages'); + const sources = this._sources(inc); + let intro = ''; + if (inc.summary) { + const ex = inc.type === 'research' ? UI.extractZusammenfassung(inc.summary) : { zusammenfassung: null }; + const short = ex.zusammenfassung + ? UI.renderZusammenfassung(ex.zusammenfassung, sources) + : UI.renderSummary(inc.summary, sources, inc.type); + intro = `
    +
    ${inc.type === 'research' ? 'Recherchebericht' : 'Lagebild'}
    + ${short}
    `; + } else { + intro = `
    Zu diesem Fall gibt es noch kein Lagebild. Starte eine Aktualisierung.
    `; + } + box.innerHTML = intro; + box.scrollTop = 0; + + // Bewusst keine Vorschlagsfragen mehr (auch nicht bei "Neu"): der Chat + // startet leer; gestellte Fragen landen in der Ereignis-Timeline. + document.getElementById('chat-suggestions').innerHTML = ''; + }, + + askChip(btn) { + document.getElementById('chat-input').value = btn.textContent; + this.sendChat(); + }, + toggleScope(on) { + this.searchAllScope = !!on; + const lbl = document.getElementById('chat-scope'); + if (lbl) lbl.classList.toggle('on', this.searchAllScope); + }, + autoGrow(ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; }, + chatKey(e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendChat(); } + }, + resetChat() { + this.convId = null; + if (this.incident) this.renderChatIntro(this.incident); + }, + + async sendChat() { + const ta = document.getElementById('chat-input'); + const msg = (ta.value || '').trim(); + if (!msg || !this.incident) return; + const box = document.getElementById('chat-messages'); + box.insertAdjacentHTML('beforeend', + `
    ${UI.escape(msg)}
    `); + ta.value = ''; this.autoGrow(ta); + const thinking = document.createElement('div'); + thinking.className = 'chat-msg chat-msg-assistant chat-thinking'; + thinking.textContent = 'Denkt nach ...'; + box.appendChild(thinking); + box.scrollTop = box.scrollHeight; + + document.getElementById('chat-send').disabled = true; + try { + const res = await API.askIncident(this.incident.id, msg, { + conversation_id: this.convId, + source_filter: this._activeSourceFilter(), + scope: this.searchAllScope ? 'alle' : 'fall', + }); + this.convId = res.conversation_id || this.convId; + thinking.classList.remove('chat-thinking'); + thinking.innerHTML = this._renderReply(res.reply || '', res.sources || []); + // Frage & Antwort wurden serverseitig protokolliert -> Timeline auffrischen + this._tlLoaded = false; + if (this.activeTab === 'timeline') this.loadTimeline(true); + } catch (e) { + thinking.classList.remove('chat-thinking'); + const notReady = e && (e.status === 404 || e.status === 405); + thinking.innerHTML = notReady + ? 'Der Lage-Chat ist noch nicht aktiviert (Backend-Neustart erforderlich).' + : '' + UI.escape((e && e.message) || 'Fehler bei der Anfrage.') + ''; + } finally { + document.getElementById('chat-send').disabled = false; + box.scrollTop = box.scrollHeight; + } + }, + + _renderReply(text, sources) { + // Zitate [n] klickbar machen -> highlightet Quelle in Spalte 1 + let html = UI.escape(text); + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + html = html.replace(/\n/g, '
    '); + html = html.replace(/\[(\d+)\]/g, (m, n) => { + const src = (sources || []).find(s => String(s.nr) === n); + const title = src ? UI.escape(src.source || src.name || '') : ''; + return `[${n}]`; + }); + return html; + }, + + gotoCite(sourceName) { + if (!sourceName) return; + const item = document.querySelector(`.src-item[data-source="${CSS.escape(sourceName)}"]`); + if (!item) { UI.showToast('Quelle nicht in der Liste', 'info'); return; } + item.classList.add('open'); + item.scrollIntoView({ behavior: 'smooth', block: 'center' }); + item.classList.remove('cite-flash'); void item.offsetWidth; + item.classList.add('cite-flash'); + }, + + // === Bausteine (Kacheln in Spalte 3) === + // Ein Klick startet den Baustein sofort. Läuft er, zeigt die Kachel Spinner + + // Fortschritt; bei den Orchestrator-Bausteinen (Sammeln, Kompletter Lauf) bricht + // ein zweiter Klick ab. Analyse/Faktencheck/Geoparsing haben backendseitig keinen + // Abbruch - ihre Kachel ist während des Laufs gesperrt statt einen toten Knopf zu zeigen. + _CANCELLABLE: ['collect', 'full'], + _STAGE_LABELS: { + collect: 'Sammeln', analyze: 'Analyse', factcheck: 'Faktencheck', + geoparse: 'Geoparsing', network: 'Netzwerkanalyse', full: 'Kompletter Lauf', + }, + + // "Aktualisieren" von außen (Tastatur/WS-Resync) = kompletter Lauf + async refresh() { return this.runStage('full'); }, + + // Alle Start-Elemente tragen data-stage: die ↻-Knoepfe in den Karten, "Kompletter + // Lauf" und der Sammeln-Knopf in der Quellenspalte. + _stageEls() { return Array.from(document.querySelectorAll('[data-stage]')); }, + _stageEl(stage) { return document.querySelector(`[data-stage="${CSS.escape(stage)}"]`); }, + + _stagesIdle() { + this._runningStage = null; + // Ohne Artikel können Analyse, Faktencheck und Geoparsing nicht laufen + // (das Backend wirft "Keine Artikel vorhanden") - erst gar nicht anbieten. + const ohneArtikel = !(this.articles || []).length; + const brauchtArtikel = ['analyze', 'factcheck', 'geoparse']; + this._stageEls().forEach(b => { + b.classList.remove('running'); + const s = b.dataset.stage; + // Netzwerkanalyse bleibt gesperrt (Baustein folgt im Backend) + b.disabled = (s === 'network') || !this.incident + || (ohneArtikel && brauchtArtikel.includes(s)); + }); + document.querySelectorAll('.art-card').forEach(c => c.classList.remove('busy')); + const ra = document.querySelector('.run-all .ra-state'); + if (ra) ra.textContent = ''; + this._renderFreshness(); + }, + + _stageRunning(stage, text) { + if (!stage) return; + this._runningStage = stage; + // Sobald etwas läuft, ist die Startfrage beantwortet - auch wenn der Lauf + // über den Sammeln-Knopf links statt über das Panel ausgelöst wurde. + const panel = document.getElementById('start-panel'); + if (panel) panel.hidden = true; + if (this.incident) localStorage.setItem('studio_start_' + this.incident.id, 'run'); + const cancellable = this._CANCELLABLE.includes(stage); + this._stageEls().forEach(b => { + const isThis = b.dataset.stage === stage; + b.classList.toggle('running', isThis); + // Die laufende Aktion bleibt nur klickbar, wenn sie abbrechbar ist + b.disabled = isThis ? !cancellable : true; + }); + document.querySelectorAll('.art-card').forEach(c => { + c.classList.toggle('busy', c.dataset.card === stage); + }); + const ra = document.querySelector('.run-all .ra-state'); + if (ra) ra.textContent = stage === 'full' ? (text || 'läuft …') + ' · Abbrechen' : ''; + this._renderFreshness(text); + }, + + // App-Zeitstempel stehen bereits in lokaler Zeit -> nur umformatieren, NICHT als UTC lesen + _fmtStamp(ts) { + const m = String(ts || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/); + return m ? `${m[3]}.${m[2]}. ${m[4]}:${m[5]}` : ''; + }, + + _setNote(card, cls, html) { + const el = document.querySelector(`.art-note[data-note="${card}"]`); + if (!el) return; + el.className = 'art-note show ' + cls; + el.innerHTML = html; + }, + + /** + * Datenstand je Karte anzeigen. Die Karte meldet selbst, ob ihr Ergebnis noch + * zur Datenlage passt - so muss niemand die Pipeline im Kopf haben. + */ + _renderFreshness(runText) { + const f = this.fresh; + const run = this._runningStage; + const spin = ''; + const btn = (stage, label) => + ``; + + // Sammeln-Knopf spiegelt den Lauf mit + const cb = document.getElementById('collect-btn'); + if (cb) cb.classList.toggle('running', run === 'collect'); + + const running = (card, label) => + this._setNote(card, 'running', `${spin} ${label} läuft …`); + const waiting = (card) => + this._setNote(card, 'blocked', + `wartet, bis ${this._STAGE_LABELS[run] || 'der Baustein'} fertig ist`); + + // Ohne Freshness-Daten (alte Lage frisch geladen / Endpunkt weg): Karten neutral lassen + const cards = ['analyze', 'factcheck', 'geoparse']; + if (run) { + cards.forEach(c => (run === c ? running(c, this._STAGE_LABELS[c]) : waiting(c))); + if (run === 'collect' || run === 'full') { + cards.forEach(c => waiting(c)); + } + } + this._setNote('network', 'blocked', 'Baustein folgt'); + this._setNote('timeline', 'fresh', 'entsteht automatisch aus den Artikeln'); + if (run || !f) return; + + const arts = f.articles || 0; + + // --- Lagebild (Analyse) --- + const s = f.summary || {}; + const sumMeta = document.getElementById('art-summary-meta'); + if (sumMeta) { + sumMeta.textContent = s.exists + ? `${arts} Artikel ausgewertet · ${this._fmtStamp(s.last)}` + : 'noch nicht erzeugt'; + } + if (!arts) { + this._setNote('analyze', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht'); + } else if (!s.exists) { + this._setNote('analyze', 'blocked', 'noch nicht erzeugt' + btn('analyze', 'Erzeugen')); + } else if (s.pending > 0) { + this._setNote('analyze', 'stale', + `${s.pending} neue Artikel seit dem letzten Bericht` + btn('analyze', 'Aktualisieren')); + } else { + this._setNote('analyze', 'fresh', 'aktuell'); + } + + // --- Faktencheck --- + const fc = f.factcheck || {}; + const fcMeta = document.getElementById('art-fc-meta'); + if (fcMeta) { + fcMeta.textContent = fc.exists + ? `${fc.facts} Fakten · ${this._fmtStamp(fc.last)}` + : 'noch nicht erzeugt'; + } + if (!arts) { + this._setNote('factcheck', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht'); + } else if (!fc.exists) { + this._setNote('factcheck', 'blocked', 'noch nicht erzeugt' + btn('factcheck', 'Erzeugen')); + } else if (fc.pending > 0) { + this._setNote('factcheck', 'stale', + `${fc.pending} neue Artikel seit dem letzten Faktencheck` + btn('factcheck', 'Aktualisieren')); + } else { + this._setNote('factcheck', 'fresh', 'aktuell'); + } + + // --- Karte (Geoparsing) --- offen ist hier nicht "neu seit", sondern "noch nicht geprüft" + const geo = f.geoparse || {}; + if (!arts) { + this._setNote('geoparse', 'blocked', 'Erst sammeln - ohne Artikel geht es nicht'); + } else if (geo.pending > 0) { + this._setNote('geoparse', 'stale', + `${geo.pending} Artikel noch nicht verortet` + btn('geoparse', 'Verorten')); + } else { + this._setNote('geoparse', 'fresh', 'alle Artikel verortet'); + } + + // --- Nebenangaben --- + const snapMeta = document.getElementById('art-snap-meta'); + if (snapMeta && !snapMeta.textContent) snapMeta.textContent = f.snapshots ? String(f.snapshots) : ''; + const tlMeta = document.getElementById('art-tl-meta'); + if (tlMeta && !tlMeta.textContent && f.events) tlMeta.textContent = `${f.events} Ereignisse`; + }, + + async runStage(stage) { + if (!this.incident) return; + + // Läuft genau dieser Baustein schon? -> Klick bedeutet Abbrechen + if (this._runningStage === stage && this._CANCELLABLE.includes(stage)) { + try { + await API.cancelRefresh(this.incident.id); + UI.showToast('Abbruch angefordert …', 'info'); + } catch (e) { + UI.showToast((e && e.message) || 'Abbruch fehlgeschlagen', 'error'); + } + return; + } + if (this._runningStage) { + UI.showToast('Es läuft bereits ein Baustein für diese Lage.', 'info'); + return; + } + + const label = this._STAGE_LABELS[stage] || 'Baustein'; + this._stageRunning(stage, 'startet …'); + try { + if (stage === 'full') { + // Beim leeren Fall ist es eine Recherche, sonst eine Aktualisierung - + // dieselbe Aktion, aber der Nutzer soll den Namen wiedererkennen, den + // er angeklickt hat. + const erstlauf = !(this.articles || []).length; + await API.refreshIncident(this.incident.id); + this._showStatus('In Warteschlange ...'); + UI.showToast(erstlauf ? 'Recherche gestartet' : 'Kompletter Lauf gestartet', 'info'); + } else if (stage === 'geoparse') { + await API.triggerGeoparse(this.incident.id); + this._showStatus('Geoparsing läuft …'); + this._pollGeoparse(); + } else { + await API.runStage(this.incident.id, stage); + this._showStatus(label + ' läuft …'); + // 'collect' läuft über die Refresh-Pipeline -> Abschluss/Reload via WebSocket + // (_wireWs: refresh_complete/refresh_error). Analyse/Faktencheck via Polling. + if (stage !== 'collect') this._pollStage(); + } + this._stageRunning(stage, 'läuft …'); + } catch (e) { + this._stagesIdle(); + this._hideStatus(); + const busy = e && e.status === 409; + UI.showToast(busy ? 'Es läuft bereits ein Baustein für diese Lage.' + : ((e && e.message) || 'Start fehlgeschlagen'), 'error'); + } + }, + _pollStage() { + if (this._stagePoll) return; + this._stagePoll = setInterval(async () => { + let res; + try { res = await API.getRunStatus(this.incident.id); } catch (e) { return; } + const st = res && res.state; + if (!st) { this._stopStagePoll(); this._hideStatus(); this._stagesIdle(); return; } + if (st.status === 'running') { + this._showStatus((st.label || 'Baustein') + ' läuft …'); + this._stageRunning(st.stage || this._runningStage, 'läuft …'); + } else { + this._stopStagePoll(); this._hideStatus(); this._stagesIdle(); + if (st.status === 'done') { + UI.showToast((st.label || 'Baustein') + ' fertig', 'info'); + this.softRefresh(); + } else if (st.status === 'error') { + UI.showToast((st.label || 'Baustein') + ' fehlgeschlagen: ' + (st.error || ''), 'error'); + } + } + }, 2000); + }, + _stopStagePoll() { if (this._stagePoll) { clearInterval(this._stagePoll); this._stagePoll = null; } }, + _pollGeoparse() { + if (this._geoPoll) return; + this._geoPoll = setInterval(async () => { + let st; + try { st = await API.getGeoparseStatus(this.incident.id); } catch (e) { return; } + if (st && st.status === 'done') { + this._stopGeoPoll(); this._hideStatus(); this._stagesIdle(); + UI.showToast('Geoparsing fertig · ' + (st.locations || 0) + ' Orte', 'info'); + this.softRefresh(); + } else if (st && st.status === 'error') { + this._stopGeoPoll(); this._hideStatus(); this._stagesIdle(); + UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error'); + } else { + // running / idle + if (st && st.total) { + const prog = (st.processed || 0) + '/' + st.total; + this._showStatus('Geoparsing läuft … ' + prog); + this._stageRunning('geoparse', prog); + } + } + }, 2000); + }, + _stopGeoPoll() { if (this._geoPoll) { clearInterval(this._geoPoll); this._geoPoll = null; } }, + async _resumeStage() { + // Beim Öffnen einer Lage: läuft dort gerade ein Baustein? -> Kachel + Polling aufnehmen + this._stopStagePoll(); + this._stopGeoPoll(); + try { + const res = await API.getRunStatus(this.incident.id); + if (res && res.state && res.state.status === 'running') { + this._showStatus((res.state.label || 'Baustein') + ' läuft …'); + this._stageRunning(res.state.stage, 'läuft …'); + this._pollStage(); + return; + } + } catch (e) { /* ignore */ } + try { + const g = await API.getGeoparseStatus(this.incident.id); + if (g && g.status === 'running') { + this._showStatus('Geoparsing läuft …'); + this._stageRunning('geoparse', 'läuft …'); + this._pollGeoparse(); + } + } catch (e) { /* ignore */ } + }, + + // Soft-Refresh (F5): Daten neu laden statt die Seite neu zu laden (wie im Monitor) + async softRefresh() { + await this.loadIncidents(); + if (!this.incident) return; + const openBefore = this.openTabs.slice(); + const activeBefore = this.activeTab; + await this.loadDetail(this.incident.id); + // geöffnete Tabs wiederherstellen (loadDetail öffnet sonst nur das Lagebild) + if (openBefore.length) { + this.openTabs = []; + document.getElementById('center-tabbar').innerHTML = ''; + openBefore.forEach(k => this.openTab(k)); + if (activeBefore && this.openTabs.includes(activeBefore)) this.activateTab(activeBefore); + } + UI.showToast('Aktualisiert', 'info'); + }, + + _showStatus(text) { + const bar = document.getElementById('live-status'); + document.getElementById('live-status-text').textContent = text; + bar.classList.add('active'); + if (!this._startTime) this._startTime = Date.now(); + if (!this._timerInt) { + this._timerInt = setInterval(() => { + const s = Math.floor((Date.now() - this._startTime) / 1000); + document.getElementById('live-status-timer').textContent = + Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); + }, 1000); + } + }, + _hideStatus() { + document.getElementById('live-status').classList.remove('active'); + if (this._timerInt) { clearInterval(this._timerInt); this._timerInt = null; } + this._startTime = null; + document.getElementById('live-status-timer').textContent = ''; + }, + + _wireWs() { + const forCurrent = (msg) => this.incident && String(msg.incident_id) === String(this.incident.id); + WS.on('status_update', (msg) => { + if (!forCurrent(msg)) return; + const label = (typeof UI._getStepLabel === 'function') ? UI._getStepLabel(msg.status) : (msg.status || ''); + this._showStatus(msg.detail || label || 'Aktualisierung läuft ...'); + }); + WS.on('pipeline_step', (msg) => { + if (!forCurrent(msg)) return; + if (msg.label || msg.step) this._showStatus(msg.label || msg.step); + }); + WS.on('refresh_complete', (msg) => { + if (!forCurrent(msg)) return; + const wasCollect = this._runningStage === 'collect'; + this._hideStatus(); + this._stagesIdle(); + UI.showToast(wasCollect ? 'Sammeln abgeschlossen' : 'Aktualisierung abgeschlossen', 'info'); + this.loadDetail(this.incident.id); + this.loadIncidents(); + }); + WS.on('refresh_error', (msg) => { + if (!forCurrent(msg)) return; + this._hideStatus(); + this._stagesIdle(); + UI.showToast('Aktualisierung fehlgeschlagen', 'error'); + }); + WS.on('refresh_cancelled', (msg) => { + if (!forCurrent(msg)) return; + this._hideStatus(); + this._stagesIdle(); + UI.showToast('Abgebrochen', 'info'); + }); + }, + + async syncRefreshStatus() { + // Nach WS-Reconnect: laufende Refreshes abgleichen + try { + const running = await API.getRefreshingIncidents(); + const ids = (running || []).map(r => String(r.incident_id || r.id)); + if (this.incident && ids.includes(String(this.incident.id))) { + this._showStatus('Aktualisierung läuft ...'); + // Welcher der beiden Orchestrator-Bausteine läuft, sagt uns der Server nicht; + // ohne bekannten Zustand als "Kompletter Lauf" anzeigen (beide sind abbrechbar). + this._stageRunning(this._runningStage || 'full', 'läuft …'); + } else { + this._hideStatus(); + if (this._runningStage === 'collect' || this._runningStage === 'full') this._stagesIdle(); + } + } catch (e) { /* ignore */ } + }, + + // === Neuen Fall anlegen (gleicher Funktionsumfang wie im Dashboard) === + openNewIncident() { + const form = document.getElementById('new-incident-form'); + form.reset(); + document.getElementById('inc-visibility').checked = true; + document.getElementById('btn-enhance-description').disabled = true; + this.incTypeDefaults(); + this.incRefreshToggle(); + this.incVisibilityHint(); + this._gateXToggle(); + const m = document.getElementById('modal-new'); + m.classList.add('active'); + setTimeout(() => document.getElementById('inc-title').focus(), 50); + }, + closeNewIncident() { + document.getElementById('modal-new').classList.remove('active'); + if (this._enhanceController) { this._enhanceController.abort(); this._enhanceController = null; } + }, + + // X-Kanäle nur freigeben, wenn ein X-Zugang hinterlegt ist (wie im Dashboard) + async _gateXToggle() { + const cb = document.getElementById('inc-x'); + const hint = document.getElementById('inc-x-hint'); + if (!cb) return; + let active = 0; + try { + const accounts = await API.listXAccounts(); + active = (accounts || []).filter(a => a.active).length; + } catch (e) { + active = 0; // im Zweifel sperren + } + cb.disabled = active === 0; + if (active === 0) cb.checked = false; + if (hint) hint.style.display = active === 0 ? '' : 'none'; + }, + + incTitleChanged() { + const t = document.getElementById('inc-title').value.trim(); + document.getElementById('btn-enhance-description').disabled = t.length < 3; + }, + + incTypeDefaults() { + const type = document.getElementById('inc-type').value; + const hint = document.getElementById('type-hint'); + const mode = document.getElementById('inc-refresh-mode'); + if (type === 'research') { + hint.textContent = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.'; + mode.value = 'manual'; + } else { + hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.'; + } + this.incRefreshToggle(); + }, + + incRefreshToggle() { + const auto = document.getElementById('inc-refresh-mode').value === 'auto'; + document.getElementById('refresh-interval-field').classList.toggle('visible', auto); + document.getElementById('refresh-starttime-field').classList.toggle('visible', auto); + }, + + incIntervalMin() { + const unit = parseInt(document.getElementById('inc-refresh-unit').value); + const input = document.getElementById('inc-refresh-value'); + const min = unit === 1 ? 10 : 1; + input.min = min; + if (parseInt(input.value) < min) input.value = min; + }, + + incVisibilityHint() { + const pub = document.getElementById('inc-visibility').checked; + document.getElementById('visibility-text').textContent = pub + ? 'Öffentlich : für alle Nutzer sichtbar' + : 'Privat : nur für dich sichtbar'; + }, + + async generateDescription() { + const title = document.getElementById('inc-title').value.trim(); + const description = document.getElementById('inc-description').value.trim(); + const type = document.getElementById('inc-type').value; + const btn = document.getElementById('btn-enhance-description'); + const btnText = document.getElementById('enhance-btn-text'); + const spinner = document.getElementById('enhance-spinner'); + const area = document.getElementById('inc-description'); + if (title.length < 3) return; + + if (this._enhanceController) this._enhanceController.abort(); + this._enhanceController = new AbortController(); + + btn.disabled = true; + btnText.textContent = 'Wird generiert …'; + spinner.style.display = ''; + area.readOnly = true; + try { + const res = await API.enhanceDescription(title, description || null, type, this._enhanceController.signal); + area.value = res.description || ''; + } catch (err) { + if (err && err.name !== 'AbortError') { + let msg = 'Beschreibung konnte nicht generiert werden'; + if (err.status === 503) msg = 'KI-Zugang aktuell nicht verfügbar.'; + else if (err.status === 429) msg = 'KI ist gerade ausgelastet. Bitte kurz warten.'; + else if (err.status === 504) msg = 'KI antwortet gerade nicht. Bitte erneut versuchen.'; + UI.showToast(msg, 'error'); + } + } finally { + spinner.style.display = 'none'; + btnText.textContent = 'Beschreibung generieren'; + area.readOnly = false; + btn.disabled = title.length < 3; + this._enhanceController = null; + } + }, + + _incidentFormData() { + const value = parseInt(document.getElementById('inc-refresh-value').value) || 15; + const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1; + const auto = document.getElementById('inc-refresh-mode').value === 'auto'; + return { + title: document.getElementById('inc-title').value.trim(), + description: document.getElementById('inc-description').value.trim() || null, + type: document.getElementById('inc-type').value, + refresh_mode: document.getElementById('inc-refresh-mode').value, + refresh_interval: Math.max(10, Math.min(10080, value * unit)), + refresh_start_time: auto ? (document.getElementById('inc-refresh-starttime').value || null) : null, + retention_days: parseInt(document.getElementById('inc-retention').value) || 0, + international_sources: document.getElementById('inc-international').checked, + include_telegram: document.getElementById('inc-telegram').checked, + include_x: document.getElementById('inc-x').checked, + visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private', + }; + }, + + async submitIncident(ev) { + ev.preventDefault(); + const btn = document.getElementById('modal-new-submit'); + const titleEl = document.getElementById('inc-title'); + if (!titleEl.value.trim()) { titleEl.focus(); return; } + + btn.disabled = true; + try { + const inc = await API.createIncident(this._incidentFormData()); + // E-Mail-Abo wie im Dashboard mitspeichern + try { + await API.updateSubscription(inc.id, { + notify_email_summary: document.getElementById('inc-notify-summary').checked, + notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked, + notify_email_status_change: document.getElementById('inc-notify-status-change').checked, + }); + } catch (e) { /* Abo ist Beiwerk - der Fall steht */ } + + this.closeNewIncident(); + await this.loadIncidents(); + await this.selectIncident(inc.id); + UI.showToast('Fall angelegt', 'success'); + } catch (e) { + UI.showToast((e && e.message) || 'Fall konnte nicht angelegt werden', 'error'); + } finally { + btn.disabled = false; + } + }, + + // === Export === + async doExport() { + if (!this.incident) return; + const format = document.getElementById('export-format').value; + const sections = ['zusammenfassung', 'bericht', 'faktencheck', 'quellen']; + try { + const resp = await API.exportReport(this.incident.id, format, null, sections); + if (!resp.ok) throw new Error('Export fehlgeschlagen'); + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `Lagebericht_${this.incident.id}.${format}`; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + UI.showToast('Export fehlgeschlagen', 'error'); + } + }, + + // === Theme === + toggleTheme() { + const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; + const next = cur === 'light' ? 'dark' : 'light'; + if (next === 'light') document.documentElement.setAttribute('data-theme', 'light'); + else document.documentElement.removeAttribute('data-theme'); + localStorage.setItem('osint_theme', next); + if (UI.updateMapTheme) UI.updateMapTheme(); + }, + + // === Zeit-Helfer === + _ts(a) { + const d = parseUTC(a.published_at || a.collected_at || a.created_at); + return d ? d.getTime() : 0; + }, + _fmtDate(a, withTime) { + const d = parseUTC(a.published_at || a.collected_at || a.created_at); + return this._fmt(d, withTime); + }, + _fmt(d, withTime) { + if (!d) return ''; + const opts = withTime + ? { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' } + : { day: '2-digit', month: '2-digit' }; + return d.toLocaleString('de-DE', opts); + }, +}; + +document.addEventListener('DOMContentLoaded', () => Studio.init()); + +// F5: Daten aktualisieren statt Seite neu laden (wie im Monitor) +document.addEventListener('keydown', (e) => { + if (e.key === 'F5') { + e.preventDefault(); + Studio.softRefresh(); + } + // ESC schließt Rueckfrage, Anlege-Dialog, Kontextmenue bzw. den Auswahlmodus + if (e.key === 'Escape') { + const c = document.getElementById('modal-confirm'); + if (c && c.classList.contains('active')) { Studio._confirmClose(false); return; } + const m = document.getElementById('modal-new'); + if (m && m.classList.contains('active')) Studio.closeNewIncident(); + Studio._closeCaseMenu(); + if (Studio.caseMode !== null) Studio.endCaseMode(); + } +}); diff --git a/src/static/studio.html b/src/static/studio.html new file mode 100644 index 0000000..7c74494 --- /dev/null +++ b/src/static/studio.html @@ -0,0 +1,522 @@ + + + + + + + + AegisSight Studio + + + + + + + + + + +
    + +
    +
    AegisSight Studio
    + + + + + + + +
    + + Aktualisierung läuft … + +
    + +
    + + Klassische Ansicht +
    +
    + + +
    + +
    + +
    + + +
    + + + + + +
    + +
    + + + + + +
    + +
    +
    +
    +
    + + +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    Noch keine Orte erkannt.
    +
    +
    +
    +
    +
    +
    + + +
    +
    Enthält Zusammenfassung, Bericht, Faktencheck und Quellen.
    +
    +
    +
    + + + + + +
    +
    Konversation + +
    +
    +
    + +
    + + +
    +
    +
    + + +
    +
    Studio
    + +
    + + + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + + Netzwerkanalyse + Akteure und Beziehungen + +
    + +
    +
    +
    + + +
    +
    + +
    +
    +
    + + +
    + +
    +
    +
    + + + +
    +
    + + + + + + + + +
    + + + + + + + + + + +