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 @@
+ Studio 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) => ` +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 = '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 ? `