Commits vergleichen
15 Commits
develop
...
1f4d7b1837
| Autor | SHA1 | Datum | |
|---|---|---|---|
| 1f4d7b1837 | |||
| d7711711aa | |||
|
|
74d76d2e50 | ||
| 0775a475a4 | |||
| 8b8e31e3cd | |||
| 4a2d85d3b8 | |||
| d24205841f | |||
| b9985b8e35 | |||
| b3bc96c580 | |||
| dc75b89618 | |||
| 44de6616f1 | |||
| bfa4d5fd78 | |||
| c57ac6c6d8 | |||
| d9e5733cfb | |||
| 9574308c29 |
@@ -1,13 +1,4 @@
|
||||
[
|
||||
{
|
||||
"version": "2026-05-03T15:21Z",
|
||||
"date": "2026-05-03",
|
||||
"title": "Übersichtlichere Navigation in der Seitenleiste",
|
||||
"items": [
|
||||
"Schaltflächen in der Seitenleiste haben jetzt klarere Icons und kürzere Beschriftungen",
|
||||
"Der Feedback-Button zeigt nun ein Brief-Symbol für bessere Erkennbarkeit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2026-04-30T23:12Z",
|
||||
"date": "2026-04-30",
|
||||
|
||||
@@ -47,6 +47,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
||||
- "summary": Zusammenfassung auf {output_language} mit Quellenverweisen [1], [2] etc. im Text (Markdown-Überschriften ## erlaubt wenn sinnvoll, aber KEINE "## ZUSAMMENFASSUNG"/"## ÜBERBLICK"-Sektion)
|
||||
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
||||
- "key_facts": Array von bestätigten Kernfakten (Strings, in Ausgabesprache)
|
||||
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
|
||||
|
||||
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||
|
||||
@@ -101,6 +102,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
||||
- "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc.
|
||||
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
||||
- "key_facts": Array von gesicherten Kernfakten (Strings, in Ausgabesprache)
|
||||
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
|
||||
|
||||
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||
|
||||
@@ -147,6 +149,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
||||
- "summary": Aktualisierte Zusammenfassung mit Quellenverweisen [1], [2] etc.
|
||||
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
|
||||
- "key_facts": Array aller aktuellen Kernfakten (in Ausgabesprache)
|
||||
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
|
||||
|
||||
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||
|
||||
@@ -198,6 +201,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
||||
- "summary": Das aktualisierte Briefing als Markdown-Text mit Quellenverweisen
|
||||
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
|
||||
- "key_facts": Array aller gesicherten Kernfakten (in Ausgabesprache)
|
||||
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
|
||||
|
||||
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||
|
||||
@@ -792,5 +796,5 @@ class AnalyzerAgent:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return {"summary": summary, "sources": sources, "key_facts": []}
|
||||
return {"summary": summary, "sources": sources, "key_facts": [], "translations": []}
|
||||
|
||||
|
||||
@@ -489,9 +489,6 @@ class AgentOrchestrator:
|
||||
|
||||
logger.info(f"Lage {incident_id} aus Warteschlange entfernt (removed={removed})")
|
||||
|
||||
# refresh_log-Eintrag schreiben, damit Auto-Refresh nicht im naechsten Tick erneut einreiht
|
||||
await self._log_queued_cancellation(incident_id)
|
||||
|
||||
# Send cancelled event
|
||||
if self._ws_manager:
|
||||
try:
|
||||
@@ -642,28 +639,6 @@ class AgentOrchestrator:
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
async def _log_queued_cancellation(self, incident_id: int):
|
||||
"""Schreibt einen cancelled-Eintrag fuer einen Queue-Abbruch (Lage war noch nicht laufend).
|
||||
Verhindert, dass der Auto-Refresh-Scheduler im naechsten Tick sofort wieder einreiht."""
|
||||
from database import get_db
|
||||
db = await get_db()
|
||||
try:
|
||||
cur = await db.execute("SELECT tenant_id FROM incidents WHERE id = ?", (incident_id,))
|
||||
row = await cur.fetchone()
|
||||
tid = row["tenant_id"] if row else None
|
||||
now_str = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
|
||||
await db.execute(
|
||||
"""INSERT INTO refresh_log (incident_id, started_at, completed_at, status,
|
||||
trigger_type, error_message, tenant_id)
|
||||
VALUES (?, ?, ?, 'cancelled', 'manual', 'Aus Warteschlange entfernt', ?)""",
|
||||
(incident_id, now_str, now_str, tid),
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Konnte Queue-Cancel nicht in refresh_log loggen: {e}")
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
async def _mark_refresh_failed(self, incident_id: int, error: str):
|
||||
"""Markiert den laufenden Refresh-Log-Eintrag als error."""
|
||||
from database import get_db
|
||||
@@ -1435,64 +1410,30 @@ class AgentOrchestrator:
|
||||
snap_articles, snap_fcs, log_id, now, tenant_id),
|
||||
)
|
||||
|
||||
# Translations werden vom dedizierten Translator-Agent unten
|
||||
# erzeugt (frueher inline im Analyzer-Output, das war token-
|
||||
# instabil und schaetzte regelmaessig content_de aus).
|
||||
# Übersetzungen aktualisieren (nur für gültige DB-IDs)
|
||||
# LLM-Drift abfangen: trotz Prompt-Anweisung kommen manchmal
|
||||
# ASCII-Umlaute ("Gespraeche" statt "Gespräche") in der Übersetzung.
|
||||
# Dictionary-basierte Korrektur schreibt nur deutsche Woerter um.
|
||||
from services.post_refresh_qc import normalize_german_umlauts as _norm_de
|
||||
for translation in analysis.get("translations", []):
|
||||
article_id = translation.get("article_id")
|
||||
if isinstance(article_id, int):
|
||||
hd = translation.get("headline_de")
|
||||
cd = translation.get("content_de")
|
||||
if hd:
|
||||
hd, _ = _norm_de(hd)
|
||||
if cd:
|
||||
cd, _ = _norm_de(cd)
|
||||
await db.execute(
|
||||
"UPDATE articles SET headline_de = ?, content_de = ? WHERE id = ? AND incident_id = ?",
|
||||
(hd, cd, article_id, incident_id),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Cancel-Check nach paralleler Verarbeitung
|
||||
self._check_cancelled(incident_id)
|
||||
|
||||
# --- Translator (Haiku) fuer fremdsprachige Artikel ohne DE-Texte ---
|
||||
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
|
||||
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
|
||||
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
|
||||
try:
|
||||
tr_cursor = await db.execute(
|
||||
"""SELECT id, headline, content_original, language
|
||||
FROM articles
|
||||
WHERE incident_id = ?
|
||||
AND language IS NOT NULL AND LOWER(language) != 'de'
|
||||
AND (headline_de IS NULL OR headline_de = ''
|
||||
OR content_de IS NULL OR content_de = '')""",
|
||||
(incident_id,),
|
||||
)
|
||||
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
|
||||
if pending_translations:
|
||||
logger.info(
|
||||
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
|
||||
incident_id, len(pending_translations),
|
||||
)
|
||||
from agents.translator import translate_articles
|
||||
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
|
||||
translations = await translate_articles(
|
||||
pending_translations,
|
||||
output_lang="de",
|
||||
usage_accumulator=usage_acc,
|
||||
)
|
||||
for t in translations:
|
||||
hd = t.get("headline_de")
|
||||
cd = t.get("content_de")
|
||||
if hd:
|
||||
hd, _ = _norm_de2(hd)
|
||||
if cd:
|
||||
cd, _ = _norm_de2(cd)
|
||||
if hd or cd:
|
||||
await db.execute(
|
||||
"UPDATE articles SET headline_de = COALESCE(?, headline_de), "
|
||||
"content_de = COALESCE(?, content_de) WHERE id = ? AND incident_id = ?",
|
||||
(hd, cd, t["id"], incident_id),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
|
||||
incident_id, len(translations), len(pending_translations),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Translator-Fehler fuer Incident %d: %s", incident_id, e, exc_info=True)
|
||||
# Refresh trotz Translator-Fehler weiterlaufen lassen
|
||||
|
||||
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
|
||||
# Basis ist jetzt das frisch generierte Lagebild (autoritativ, thematisch sauber).
|
||||
# Zeitstempel und Quellen kommen aus den jüngsten belegenden Artikeln.
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
"""Translator-Agent: uebersetzt fremdsprachige Artikel ins Deutsche.
|
||||
|
||||
Eigener Agent (separat vom Analyzer), damit Token-Limits nicht zwischen
|
||||
Lagebild und Uebersetzung konkurrieren. Nutzt CLAUDE_MODEL_FAST (Haiku) in
|
||||
Batches.
|
||||
|
||||
Aufgerufen vom Orchestrator nach analyzer.analyze() und vor post_refresh_qc.
|
||||
Backfill-Skript nutzt dieselbe Funktion fuer rueckwirkendes Auffuellen.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
|
||||
from config import CLAUDE_MODEL_FAST, TRANSLATOR_ENABLED
|
||||
|
||||
logger = logging.getLogger("osint.translator")
|
||||
|
||||
# Pro Batch nicht mehr als so viele Artikel an Claude geben.
|
||||
# Bei Haiku ist das Output-Limit ca. 8k Tokens. Pro Artikel kommen leicht
|
||||
# 400-600 Tokens raus (headline_de + content_de bis 1000 Zeichen). Bei 15
|
||||
# wurde regelmaessig getrunkt (mid-JSON broken). 5 ist sicher mit Reserve.
|
||||
DEFAULT_BATCH_SIZE = 5
|
||||
|
||||
# content_original wird ohnehin auf 1000 Zeichen gecappt (rss_parser).
|
||||
# Fuer den Translator nochmal verkuerzen, falls vorhanden mehr.
|
||||
CONTENT_INPUT_MAX = 1200
|
||||
|
||||
# content_de soll wie content_original auf 1000 Zeichen begrenzt sein.
|
||||
CONTENT_OUTPUT_MAX = 1000
|
||||
|
||||
|
||||
def _extract_complete_objects(text: str) -> list[dict]:
|
||||
"""Extrahiert vollstaendige JSON-Objekte aus moeglicherweise abgeschnittenem Text.
|
||||
|
||||
Klammer-Counter-Ansatz: jedes balancierte {...} wird probiert.
|
||||
"""
|
||||
results = []
|
||||
depth = 0
|
||||
start = -1
|
||||
in_string = False
|
||||
escape = False
|
||||
for i, ch in enumerate(text):
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
continue
|
||||
if ch == '"' and not escape:
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == "{":
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0 and start >= 0:
|
||||
obj_text = text[start:i + 1]
|
||||
try:
|
||||
obj = json.loads(obj_text)
|
||||
if isinstance(obj, dict):
|
||||
results.append(obj)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
start = -1
|
||||
return results
|
||||
|
||||
|
||||
def _build_prompt(articles: list[dict], output_lang: str = "de") -> str:
|
||||
"""Bauen den Translation-Prompt fuer eine Batch."""
|
||||
lang_label = {"de": "Deutsch", "en": "Englisch"}.get(output_lang, output_lang)
|
||||
|
||||
items = []
|
||||
for a in articles:
|
||||
items.append({
|
||||
"id": a["id"],
|
||||
"headline": a.get("headline", "") or "",
|
||||
"content": (a.get("content_original") or "")[:CONTENT_INPUT_MAX],
|
||||
"source_lang": a.get("language", "en"),
|
||||
})
|
||||
|
||||
return f"""Du bist ein praeziser Uebersetzer fuer Nachrichten-Artikel.
|
||||
Uebersetze die folgenden Artikel nach {lang_label}.
|
||||
|
||||
WICHTIG:
|
||||
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) - NIEMALS Umschreibungen wie ae, oe, ue, ss.
|
||||
Beispiele: "Gespraeche" -> "Gespräche", "Fuehrer" -> "Führer", "grosse" -> "große".
|
||||
- Behalte Eigennamen (Personen, Orte, Organisationen) im Original.
|
||||
- Headline kurz und buendig wie im Original.
|
||||
- Content auf MAX {CONTENT_OUTPUT_MAX} Zeichen kuerzen, kein HTML, kein Markdown.
|
||||
- Wenn der Artikel schon auf {lang_label} ist (z.B. source_lang="{output_lang}"),
|
||||
kopiere headline und content unveraendert.
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit einem flachen JSON-Array (kein Wrapper-Objekt!).
|
||||
Format genau so:
|
||||
[
|
||||
{{"id": 1, "headline_de": "Titel auf Deutsch", "content_de": "Inhalt auf Deutsch"}},
|
||||
{{"id": 2, "headline_de": "...", "content_de": "..."}}
|
||||
]
|
||||
|
||||
NICHT erlaubt: {{"translations": [...]}} oder {{"items": [...]}} oder Markdown-Codefences.
|
||||
Nur das Array, ohne Einleitung, ohne Erklaerung.
|
||||
|
||||
ARTIKEL:
|
||||
{json.dumps(items, ensure_ascii=False, indent=2)}
|
||||
"""
|
||||
|
||||
|
||||
def _parse_response(text: str) -> list[dict]:
|
||||
"""Robustes JSON-Array-Parsing.
|
||||
|
||||
Handhabt:
|
||||
- reines JSON
|
||||
- JSON in Markdown-Codefence ```json ... ```
|
||||
- abgeschnittene Antworten (extrahiert vollstaendige Top-Level-Objekte)
|
||||
"""
|
||||
text = text.strip()
|
||||
# Markdown-Codefence entfernen
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```\s*$", "", text)
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# Erst Array versuchen
|
||||
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
data = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
# Truncate-Fallback: einzelne Top-Level-Objekte extrahieren
|
||||
data = _extract_complete_objects(text)
|
||||
else:
|
||||
data = _extract_complete_objects(text)
|
||||
|
||||
# Claude wraps das Array gelegentlich in {"translations": [...]} oder {"items": [...]}
|
||||
if isinstance(data, dict):
|
||||
for key in ("translations", "items", "results", "data"):
|
||||
if isinstance(data.get(key), list):
|
||||
data = data[key]
|
||||
break
|
||||
else:
|
||||
# Einzelnes Objekt? Dann als Liste mit einem Element behandeln
|
||||
if "id" in data:
|
||||
data = [data]
|
||||
else:
|
||||
raise ValueError(f"Translator-Antwort: Dict ohne erwarteten Array-Key (keys={list(data.keys())[:5]})")
|
||||
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"Translator-Antwort ist kein Array: {type(data).__name__}")
|
||||
|
||||
cleaned = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
aid = item.get("id")
|
||||
if not isinstance(aid, int):
|
||||
try:
|
||||
aid = int(aid)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
cleaned.append({
|
||||
"id": aid,
|
||||
"headline_de": (item.get("headline_de") or "").strip() or None,
|
||||
"content_de": (item.get("content_de") or "").strip() or None,
|
||||
})
|
||||
return cleaned
|
||||
|
||||
|
||||
async def translate_articles_batch(
|
||||
articles: list[dict],
|
||||
output_lang: str = "de",
|
||||
) -> tuple[list[dict], ClaudeUsage]:
|
||||
"""Uebersetzt eine Batch von Artikeln.
|
||||
|
||||
Erwartet articles als Liste von Dicts mit den Feldern id, headline,
|
||||
content_original, language.
|
||||
|
||||
Rueckgabe: (uebersetzte_artikel, usage)
|
||||
Wenn der Call fehlschlaegt, wird ([], leere_usage) zurueckgegeben - der
|
||||
Caller kann entscheiden, ob retry oder skip.
|
||||
"""
|
||||
if not articles:
|
||||
return [], ClaudeUsage()
|
||||
|
||||
prompt = _build_prompt(articles, output_lang)
|
||||
|
||||
try:
|
||||
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||
except Exception as e:
|
||||
logger.error(f"Translator Claude-Call fehlgeschlagen: {e}")
|
||||
return [], ClaudeUsage()
|
||||
|
||||
try:
|
||||
translations = _parse_response(result_text)
|
||||
except Exception as e:
|
||||
logger.error(f"Translator JSON-Parsing fehlgeschlagen: {e}; raw: {result_text[:300]!r}")
|
||||
return [], usage
|
||||
|
||||
# Validierung: nur Translations zurueckgeben, deren id wirklich
|
||||
# in der angefragten Batch war
|
||||
requested_ids = {a["id"] for a in articles}
|
||||
valid = [t for t in translations if t["id"] in requested_ids]
|
||||
if len(valid) != len(translations):
|
||||
logger.warning(
|
||||
"Translator: %d von %d Translations referenzieren unbekannte IDs",
|
||||
len(translations) - len(valid), len(translations),
|
||||
)
|
||||
return valid, usage
|
||||
|
||||
|
||||
async def translate_articles(
|
||||
articles: list[dict],
|
||||
output_lang: str = "de",
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
usage_accumulator: UsageAccumulator | None = None,
|
||||
) -> list[dict]:
|
||||
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
|
||||
|
||||
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
|
||||
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
|
||||
wird er uebersprungen (anderer Batches laufen weiter).
|
||||
"""
|
||||
if not articles:
|
||||
return []
|
||||
|
||||
if not TRANSLATOR_ENABLED:
|
||||
logger.info(
|
||||
"Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen",
|
||||
len(articles),
|
||||
)
|
||||
return []
|
||||
|
||||
all_translations = []
|
||||
for i in range(0, len(articles), batch_size):
|
||||
batch = articles[i : i + batch_size]
|
||||
translations, usage = await translate_articles_batch(batch, output_lang)
|
||||
if usage_accumulator is not None:
|
||||
usage_accumulator.add(usage)
|
||||
all_translations.extend(translations)
|
||||
logger.info(
|
||||
"Translator-Batch %d/%d: %d/%d uebersetzt (cost=$%.4f)",
|
||||
(i // batch_size) + 1,
|
||||
(len(articles) + batch_size - 1) // batch_size,
|
||||
len(translations), len(batch),
|
||||
usage.cost_usd,
|
||||
)
|
||||
return all_translations
|
||||
@@ -41,10 +41,6 @@ OUTPUT_LANGUAGE = "Deutsch"
|
||||
# In Kundenversion auf False setzen oder Env-Variable entfernen
|
||||
DEV_MODE = os.environ.get("DEV_MODE", "true").lower() == "true"
|
||||
|
||||
# Feature-Flag: Translator-Agent (Haiku) komplett deaktivieren.
|
||||
# False = keine Uebersetzungen mehr, fremdsprachige Artikel bleiben unuebersetzt.
|
||||
TRANSLATOR_ENABLED = os.environ.get("TRANSLATOR_ENABLED", "true").lower() == "true"
|
||||
|
||||
# RSS-Feeds (Fallback, primär aus DB geladen)
|
||||
RSS_FEEDS = {
|
||||
"deutsch": [
|
||||
|
||||
@@ -124,7 +124,7 @@ async def check_auto_refresh():
|
||||
|
||||
# Letzten abgeschlossenen oder laufenden Refresh pruefen
|
||||
cursor = await db.execute(
|
||||
"SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running', 'cancelled', 'error') ORDER BY id DESC LIMIT 1",
|
||||
"SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running') ORDER BY id DESC LIMIT 1",
|
||||
(incident_id,),
|
||||
)
|
||||
last_refresh = await cursor.fetchone()
|
||||
|
||||
@@ -47,7 +47,7 @@ async def require_writable_license(
|
||||
if lic.get("read_only"):
|
||||
reason = lic.get("read_only_reason") or "expired"
|
||||
if reason == "budget_exceeded":
|
||||
detail = "Token-Budget aufgebraucht. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren."
|
||||
detail = "Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren."
|
||||
elif reason == "expired":
|
||||
detail = "Lizenz abgelaufen. Nur Lesezugriff moeglich."
|
||||
elif reason == "no_license":
|
||||
|
||||
@@ -549,31 +549,6 @@ a:hover {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-dropdown-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.header-dropdown-action:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.header-dropdown-action svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.header-license-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -72,11 +72,6 @@
|
||||
<span class="credits-percent" id="credits-percent"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credits-divider"></div>
|
||||
<button class="header-dropdown-action" type="button" onclick="AIDisclaimer && AIDisclaimer.show()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
<span>Über KI-Inhalte</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-license-warning" id="header-license-warning"></div>
|
||||
@@ -123,14 +118,8 @@
|
||||
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="sidebar-sources-link">
|
||||
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()" title="Quellen verwalten">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>
|
||||
<span>Quellen</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()" title="Feedback senden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-10 5L2 7"/></svg>
|
||||
<span>Feedback</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()">Quellen verwalten</button>
|
||||
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</button>
|
||||
<!-- Tutorial-Einstieg temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
|
||||
<button class="btn btn-secondary btn-full btn-small" onclick="Tutorial.start()" title="Interaktiven Rundgang starten">Rundgang starten</button>
|
||||
-->
|
||||
@@ -749,6 +738,5 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/js/update-system.js"></script>
|
||||
<script src="/static/js/ai-disclaimer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* AI-Hallucination-Disclaimer fuer den AegisSight Monitor.
|
||||
*
|
||||
* Zeigt:
|
||||
* 1) Beim ersten Besuch (oder bei neuem v-Bump) ein Modal mit Hinweisen
|
||||
* zur Fehlbarkeit von KI-Modellen.
|
||||
* 2) Im Header-User-Dropdown immer einen Eintrag "Ueber KI-Inhalte",
|
||||
* ueber den der User das Modal jederzeit erneut oeffnen kann.
|
||||
*
|
||||
* Persistenz:
|
||||
* localStorage 'aegis_ai_disclaimer_seen' -> Versionsstring (z.B. "v1").
|
||||
* Wenn die Version sich aendert (Wortlaut-Update), erscheint das Modal
|
||||
* beim naechsten Login erneut.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'aegis_ai_disclaimer_seen';
|
||||
const CURRENT_VERSION = 'v1';
|
||||
|
||||
// ---- DOM-Helpers (analog zu update-system.js) ----
|
||||
function el(tag, attrs, ...children) {
|
||||
const e = document.createElement(tag);
|
||||
for (const k in (attrs || {})) {
|
||||
if (k === 'class') e.className = attrs[k];
|
||||
else if (k === 'html') e.innerHTML = attrs[k];
|
||||
else if (k.startsWith('on')) e.addEventListener(k.slice(2), attrs[k]);
|
||||
else e.setAttribute(k, attrs[k]);
|
||||
}
|
||||
for (const c of children) {
|
||||
if (c == null) continue;
|
||||
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('aegis-aidisc-styles')) return;
|
||||
const css = `
|
||||
#aegis-aidisc-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 99998;
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
animation: aegis-aidisc-fade 0.25s ease;
|
||||
}
|
||||
@keyframes aegis-aidisc-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
#aegis-aidisc-modal {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.4);
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
max-width: 580px; width: 100%; max-height: 85vh; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#aegis-aidisc-modal header {
|
||||
padding: 22px 28px 18px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
#aegis-aidisc-modal header svg { color: var(--accent); flex-shrink: 0; }
|
||||
#aegis-aidisc-modal h2 { margin: 0; color: var(--accent); font-size: 1.25rem; font-weight: 700; }
|
||||
#aegis-aidisc-modal .body { padding: 18px 28px; overflow-y: auto; line-height: 1.55; }
|
||||
#aegis-aidisc-modal .body p { margin: 0 0 12px; color: var(--text-primary); font-size: 0.94rem; }
|
||||
#aegis-aidisc-modal .body strong { color: var(--accent); }
|
||||
#aegis-aidisc-modal .body ul { margin: 8px 0 14px; padding-left: 22px; }
|
||||
#aegis-aidisc-modal .body li { margin-bottom: 6px; color: var(--text-secondary); font-size: 0.92rem; }
|
||||
#aegis-aidisc-modal .footnote {
|
||||
margin-top: 10px; padding-top: 12px; border-top: 1px solid var(--border);
|
||||
color: var(--text-tertiary); font-size: 0.82rem;
|
||||
}
|
||||
#aegis-aidisc-modal footer {
|
||||
padding: 14px 28px 20px; border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
}
|
||||
#aegis-aidisc-modal footer button {
|
||||
background: var(--accent); color: #fff; border: 0; padding: 10px 22px;
|
||||
border-radius: 6px; font: inherit; font-size: 0.92rem; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
#aegis-aidisc-modal footer button:hover { background: var(--accent-hover); }
|
||||
#aegis-aidisc-modal footer button.secondary {
|
||||
background: transparent; color: var(--text-secondary); border: 1px solid var(--border);
|
||||
}
|
||||
#aegis-aidisc-modal footer button.secondary:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.04)); color: var(--text-primary);
|
||||
}`;
|
||||
document.head.appendChild(el('style', { id: 'aegis-aidisc-styles', html: css }));
|
||||
}
|
||||
|
||||
// ---- Modal-Aufbau ----
|
||||
function buildModal(opts) {
|
||||
const isFromUser = !!(opts && opts.fromUserAction);
|
||||
|
||||
// Lucide info-Icon (gleiches Pattern wie .info-icon im Repo)
|
||||
const headerIcon = el('span', {
|
||||
html: '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" '
|
||||
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
|
||||
+ 'stroke-linecap="round" stroke-linejoin="round">'
|
||||
+ '<circle cx="12" cy="12" r="10"/>'
|
||||
+ '<path d="M12 16v-4"/><path d="M12 8h.01"/></svg>'
|
||||
});
|
||||
|
||||
const body = el('div', { class: 'body' });
|
||||
body.appendChild(el('p', null,
|
||||
'Der AegisSight Monitor nutzt Künstliche Intelligenz '
|
||||
+ 'zur Analyse, Übersetzung und Zusammenfassung von Nachrichten.'));
|
||||
|
||||
const warn = el('p');
|
||||
warn.innerHTML = '<strong>KI-Modelle können Fehler machen</strong> '
|
||||
+ '(sogenannte „Halluzinationen"): erfundene Details, falsche Verbindungen oder '
|
||||
+ 'ungenaue Zusammenfassungen sind möglich, auch wenn der Text plausibel klingt.';
|
||||
body.appendChild(warn);
|
||||
|
||||
body.appendChild(el('p', null, 'Wir empfehlen daher:'));
|
||||
body.appendChild(el('ul', null,
|
||||
el('li', null, 'Wichtige Informationen mit den verlinkten Quellen verifizieren'),
|
||||
el('li', null, 'Bei kritischen Entscheidungen die Originalartikel prüfen'),
|
||||
el('li', null, 'Faktenchecks als Hinweis verstehen, nicht als endgültige Wahrheit')
|
||||
));
|
||||
|
||||
body.appendChild(el('p', { class: 'footnote' },
|
||||
'Diesen Hinweis findest du jederzeit wieder im Menü oben rechts unter „Über KI-Inhalte".'));
|
||||
|
||||
const closeAndStore = () => {
|
||||
try { localStorage.setItem(STORAGE_KEY, CURRENT_VERSION); } catch (e) {}
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
const closeOnly = () => {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
|
||||
const footer = el('footer', null);
|
||||
if (!isFromUser) {
|
||||
footer.appendChild(el('button', { class: 'secondary', onclick: closeOnly }, 'Später nochmal'));
|
||||
}
|
||||
footer.appendChild(el('button', { onclick: closeAndStore }, 'Verstanden'));
|
||||
|
||||
const overlay = el('div', { id: 'aegis-aidisc-overlay' },
|
||||
el('div', { id: 'aegis-aidisc-modal' },
|
||||
el('header', null, headerIcon, el('h2', null, 'Hinweis zu KI-generierten Inhalten')),
|
||||
body,
|
||||
footer
|
||||
)
|
||||
);
|
||||
|
||||
function escHandler(ev) {
|
||||
if (ev.key === 'Escape' && document.getElementById('aegis-aidisc-overlay')) {
|
||||
// ESC = wie "Verstanden" beim erstmaligen Anzeigen, sonst nur schliessen
|
||||
if (isFromUser) closeOnly(); else closeAndStore();
|
||||
}
|
||||
}
|
||||
overlay.addEventListener('click', (ev) => {
|
||||
if (ev.target === overlay) {
|
||||
if (isFromUser) closeOnly(); else closeAndStore();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function show(opts) {
|
||||
if (document.getElementById('aegis-aidisc-overlay')) return;
|
||||
injectStyles();
|
||||
document.body.appendChild(buildModal(opts));
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Nur auf der Dashboard-Seite zeigen, nicht auf der Login-Seite
|
||||
if (!document.body || document.body.classList.contains('login-page')) return;
|
||||
|
||||
injectStyles();
|
||||
let seenVersion = '';
|
||||
try { seenVersion = localStorage.getItem(STORAGE_KEY) || ''; } catch (e) {}
|
||||
if (seenVersion !== CURRENT_VERSION) {
|
||||
// Etwas verzoegern, damit Hauptdashboard sichtbar ist bevor Modal kommt
|
||||
setTimeout(() => show({ fromUserAction: false }), 600);
|
||||
}
|
||||
}
|
||||
|
||||
// Globaler Zugriff zum manuellen Oeffnen aus dem Header-Dropdown
|
||||
window.AIDisclaimer = {
|
||||
show: () => show({ fromUserAction: true }),
|
||||
VERSION: CURRENT_VERSION,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -523,7 +523,7 @@ const App = {
|
||||
let text = 'Nur Lesezugriff';
|
||||
const reason = user.read_only_reason;
|
||||
if (reason === 'budget_exceeded') {
|
||||
text = 'Token-Budget aufgebraucht – nur Lesezugriff. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren.';
|
||||
text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.';
|
||||
} else if (reason === 'expired') {
|
||||
text = 'Lizenz abgelaufen – nur Lesezugriff';
|
||||
} else if (reason === 'no_license') {
|
||||
@@ -618,10 +618,6 @@ const App = {
|
||||
const inc = this.incidents.find(i => i.id === id);
|
||||
const isFirst = inc && !inc.has_summary;
|
||||
UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
|
||||
// Pipeline-Reset auch nach F5: aktive Lage in Queue -> Icons grau
|
||||
if (id === this.currentIncidentId && typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
|
||||
Pipeline.beginQueue(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1930,11 +1926,6 @@ async handleRefresh() {
|
||||
this._updateRefreshButton(true);
|
||||
// showProgress called via handleStatusUpdate
|
||||
const result = await API.refreshIncident(this.currentIncidentId);
|
||||
// Pipeline auf "pending" setzen, damit alte gruene Haekchen nicht
|
||||
// faelschlich "schon fertig" suggerieren waehrend die Lage in der Queue steht
|
||||
if (typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
|
||||
Pipeline.beginQueue(this.currentIncidentId);
|
||||
}
|
||||
if (result && result.status === 'skipped') {
|
||||
UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
|
||||
} else {
|
||||
|
||||
@@ -354,22 +354,9 @@ const UI = {
|
||||
const minBtn = document.getElementById('progress-popup-minimize');
|
||||
if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
|
||||
|
||||
// Title - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft)
|
||||
// Title
|
||||
const titleEl = document.getElementById('progress-popup-title');
|
||||
if (titleEl) {
|
||||
let title;
|
||||
if (status === 'queued') {
|
||||
const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : '';
|
||||
title = 'In Warteschlange' + pos;
|
||||
} else if (status === 'cancelling') {
|
||||
title = 'Wird abgebrochen\u2026';
|
||||
} else if (state.isFirst) {
|
||||
title = 'Erste Recherche l\u00e4uft';
|
||||
} else {
|
||||
title = 'Aktualisierung l\u00e4uft';
|
||||
}
|
||||
titleEl.textContent = title;
|
||||
}
|
||||
if (titleEl) titleEl.textContent = state.isFirst ? 'Erste Recherche l\u00e4uft' : 'Aktualisierung l\u00e4uft';
|
||||
|
||||
// Multi-pass info
|
||||
const passEl = document.getElementById('progress-popup-pass');
|
||||
|
||||
@@ -19,7 +19,6 @@ const Pipeline = {
|
||||
_incidentId: null,
|
||||
_definition: null, // PIPELINE_STEPS vom Backend
|
||||
_stateByKey: {}, // step_key -> {status, count_value, count_secondary, pass_number}
|
||||
_snapshotState: null, // deep-copy von _stateByKey vor Refresh-Start (fuer Cancel-Restore)
|
||||
_isResearch: false,
|
||||
_passTotal: 1,
|
||||
_lastRefreshHeader: null,
|
||||
@@ -43,11 +42,10 @@ const Pipeline = {
|
||||
if (this._wsBound) return;
|
||||
if (typeof WS !== 'undefined' && WS.on) {
|
||||
WS.on('pipeline_step', (msg) => this._onWsStep(msg));
|
||||
// Erfolg: API-State neu laden (finaler Stand sichtbar)
|
||||
WS.on('refresh_complete', (msg) => this._onRefreshDoneSuccess(msg));
|
||||
// Cancel/Error: vor-Refresh-Snapshot zurueckspielen, damit Pipeline nicht im Mix-Zustand stehen bleibt
|
||||
WS.on('refresh_cancelled', (msg) => this._onRefreshDoneCancel(msg));
|
||||
WS.on('refresh_error', (msg) => this._onRefreshDoneError(msg));
|
||||
// Bei Refresh-Complete den finalen Stand neu laden, damit Zahlen gefroren sichtbar bleiben
|
||||
WS.on('refresh_complete', (msg) => this._onRefreshDone(msg));
|
||||
WS.on('refresh_cancelled', (msg) => this._onRefreshDone(msg));
|
||||
WS.on('refresh_error', (msg) => this._onRefreshDone(msg));
|
||||
this._wsBound = true;
|
||||
}
|
||||
// Hover-Tooltip-Element vorbereiten
|
||||
@@ -70,7 +68,6 @@ const Pipeline = {
|
||||
async bindToIncident(incidentId) {
|
||||
this._incidentId = incidentId;
|
||||
this._stateByKey = {};
|
||||
this._snapshotState = null; // Snapshot ist immer lagen-spezifisch
|
||||
this._isResearch = false;
|
||||
this._passTotal = 1;
|
||||
this._lastRefreshHeader = null;
|
||||
@@ -104,20 +101,6 @@ const Pipeline = {
|
||||
|
||||
this._render();
|
||||
this._renderMini();
|
||||
|
||||
// Edge-Case: Lage ist gerade in Queue (z.B. via Lagen-Wechsel beim
|
||||
// Klick in der Sidebar). API liefert den LETZTEN gespeicherten Stand
|
||||
// (alles done = gruen), aber tatsaechlich wartet ein neuer Refresh.
|
||||
// -> beginQueue() selbst ausloesen, damit Icons grau zeigen.
|
||||
try {
|
||||
if (typeof App !== 'undefined' && App._refreshingIncidents
|
||||
&& App._refreshingIncidents.has(incidentId)
|
||||
&& typeof UI !== 'undefined' && UI._progressState
|
||||
&& UI._progressState[incidentId]
|
||||
&& UI._progressState[incidentId].step === 'queued') {
|
||||
this.beginQueue(incidentId);
|
||||
}
|
||||
} catch (e) { /* tolerant */ }
|
||||
} catch (e) {
|
||||
console.warn('Pipeline laden fehlgeschlagen:', e);
|
||||
this._renderEmpty('Pipeline-Daten konnten nicht geladen werden.');
|
||||
@@ -183,65 +166,14 @@ const Pipeline = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wird vom Frontend gerufen, wenn ein Refresh angestossen wurde (queued).
|
||||
* Macht einen Snapshot des aktuellen Pipeline-Stands (zur spaeteren Wiederherstellung
|
||||
* bei Cancel/Error) und setzt dann alle Steps auf "pending" - damit der User sieht:
|
||||
* "neuer Refresh laeuft an, alte gruene Haekchen sind nicht mehr aktuell".
|
||||
*/
|
||||
beginQueue(incidentId) {
|
||||
if (this._incidentId !== incidentId) return; // andere Lage offen
|
||||
if (!this._definition) return; // noch keine Pipeline-Definition geladen
|
||||
// Aktuellen Stand sichern (deep-copy). Bei Mehrfach-Refresh ohne Cancel
|
||||
// dazwischen wird der Snapshot bewusst ueberschrieben - er soll immer
|
||||
// der "Stand kurz vor diesem Refresh" sein.
|
||||
this._snapshotState = JSON.parse(JSON.stringify(this._stateByKey));
|
||||
// Alle Steps auf pending setzen
|
||||
this._definition.forEach(s => {
|
||||
if (this._stateByKey[s.key]) {
|
||||
this._stateByKey[s.key].status = 'pending';
|
||||
} else {
|
||||
this._stateByKey[s.key] = { status: 'pending', count_value: null, count_secondary: null, pass_number: 1 };
|
||||
}
|
||||
});
|
||||
this._render();
|
||||
this._renderMini();
|
||||
},
|
||||
|
||||
/** Restauriert den letzten Snapshot. Rueckgabe: true bei Erfolg, false wenn keiner da war. */
|
||||
_restoreSnapshot() {
|
||||
if (!this._snapshotState) return false;
|
||||
this._stateByKey = this._snapshotState;
|
||||
this._snapshotState = null;
|
||||
this._render();
|
||||
this._renderMini();
|
||||
return true;
|
||||
},
|
||||
|
||||
_onRefreshDoneSuccess(msg) {
|
||||
_onRefreshDone(msg) {
|
||||
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
|
||||
this._snapshotState = null; // verworfen, neuer Stand wird vom API geladen
|
||||
// Daten frisch nachladen, damit Header (Dauer) und finale Zahlen passen
|
||||
setTimeout(() => {
|
||||
if (this._incidentId != null) this.bindToIncident(this._incidentId);
|
||||
}, 600);
|
||||
},
|
||||
|
||||
_onRefreshDoneCancel(msg) {
|
||||
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
|
||||
if (!this._restoreSnapshot()) {
|
||||
// Kein Snapshot vorhanden (z.B. Page-Reload mitten im Refresh) -> wie bisher API-Reload
|
||||
setTimeout(() => {
|
||||
if (this._incidentId != null) this.bindToIncident(this._incidentId);
|
||||
}, 600);
|
||||
}
|
||||
},
|
||||
|
||||
_onRefreshDoneError(msg) {
|
||||
// Wie Cancel: vorheriger Stand zurueck (nicht im Mix-Zustand stehenbleiben)
|
||||
this._onRefreshDoneCancel(msg);
|
||||
},
|
||||
|
||||
/** Vollbild-Pipeline (Tab "Analysepipeline") als 3x3-Snake rendern. */
|
||||
_render() {
|
||||
const stage = document.getElementById('pipeline-stage');
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren