<!-- Erstellt mit Qwirbel (KI-Assistent) am 27.08.2026 mit unbekanntem Modell ueber zai (Cloud-API) – KI-generierter Inhalt, Kennzeichnung nach Art. 50 EU-KI-VO. -->
# EMPFANGSPFAD: EIN Codepfad je Engine – Bauplan
Datum: 27.08.2026 · Ziel-Projekt: %USERPROFILE%\Desktop\Qwirbel
Auftrag: Stream-Stuecke/Ende/Fehler/Abbruch je Engine auf EINEN Codepfad – keine If-Kette verwirft Teilantworten, kein Spinner bleibt offen.
Status: Plan steht, Umsetzung offen (Schritt 0 zuerst!).

## Belegter Ist-Zustand (alles selbst gelesen)

### backend/api_manager.py (1202 Zeilen)
- Z.358 `call_stream`: DER eine Eingang. Gates Z.380-402 (komplett-lokal, enabled, key, video).
- Z.403-412 ollama: `yield from ollama_client.generate(...)` – Fehlerbehandlung liegt in ollama_client (dort Z.256 bewusst OHNE Failover).
- Z.413-439 klecks: KEIN Streaming, EIN `yield antwort` (Z.438) – so vereinbart, unangetastet lassen.
- Z.447-456 If-Kette: anthropic / gemini / sonst openai → `_stream_anthropic` / `_stream_gemini` / `_stream_openai`, JEDE mit eigener Iterations-/Fehlerbehandlung.
- yield-Lagen der Engine-Loops: Z.820, 993, 1047, 1157, 1172, 1177 (anthropic ~ab 780, gemini ~ab 950, openai ~ab 1100 – Z.700-1202 noch NICHT komplett gelesen → Schritt 0).
- Z.617-690 `call_stream_failover`: emitted-Flag + 4 except-Zweige:
  - DLPBlockiert → re-raise (Z.668-669, korrekt, BLEIBT)
  - APIFehler nach emitted → Abbruch-Hinweis + return (Z.670-675)
  - RequestException nach emitted → Hinweis + return (Z.677-682)
  - **LÜCKE 1: generische Exception nach emitted → nutzung übernehmen + STILL return (Z.684-688), KEIN Hinweis – abgeschnittene Antwort sieht komplett aus.**

### Backend-Aufrufer (eigene If-Ketten drumrum)
- backend/main.py Z.2878: `yield from call_stream_failover` (dünn, OK, bleibt)
- backend/main.py Z.6961: eigene `for stueck in ...`-Schleife
- backend/main.py Z.7915, 8591, 9755: `gen = call_stream_failover(...)` + je eigene Behandlung
- backend/agent.py Z.599: `"".join(...)`-Verbraucher, kein UI-Spinner → unkritisch, bleibt

### frontend/index.html (31636 Zeilen)
- **LÜCKE 2 (Spinner): SIEBEN getReader-Leseschleifen – Z.5385, 5945, 6110, 8434, 10631, 21014, 21274 – jede entscheidet selbst über Ende/Fehler/Abbruch.**
- **LÜCKE 3: qwirbeln() ab Z.5366 läuft OHNE AbortController/signal** ( deckt sich mit Schwarm-Thema „Abbruch und Zeitwaechter“).
- Chat-Send HAT Controller: Z.5887, Z.6087.
- frontend/gebaut/app.js ist (Babel-)Build-Produkt → NIE von Hand editieren, nur index.html ändern + app.js neu generieren (Weg in Schritt 0 klären).

## Zielbild – EINE Wahrheit je Ebene
- Backend: die drei `_stream_*` werden zu DÜNNEN Quellfunktionen (nur Protokoll-/SSE-Parsing + yield). EINE Empfangs-Schleife `strom_empfangen()` regelt Stuecke/Ende/Fehler/Abbruch für ALLE Engines; Failover stützt sich auf dieselbe Schleife.
- Frontend: EINE Funktion `liesStrom(res, {onZeile, signal})` mit finally (Spinner IMMER zu), alle 7 Schleifen rufen nur noch sie.

## Schritte (kleinstmöglich, Abhängigkeiten zuerst)

### Schritt 0 – Restlektüre (PFLICHT vor jedem Edit; nichts blind ändern)
- 0.1 api_manager.py Z.700-1202 komplett (die drei _stream_* inkl. ihrer try/except).
- 0.2 main.py um Z.6961, 7915, 8591, 9755 je ±40 Zeilen.
- 0.3 index.html um die 7 getReader-Stellen je ±60 Zeilen (Event-Formate klären: SSE vs NDJSON).
- 0.4 qwirbeln() Z.5366-5390 (signal-Fehlstelle bestätigen).
- 0.5 Klären, wie frontend/gebaut/app.js gebaut wird.
- FERTIG-WENN: keine Annahme mehr über Signaturen/Event-Formate.

### Schritt 1 – Backend: EINE Empfangs-Schleife (api_manager.py, neu)
```python
def strom_empfangen(quelle, nutzung=None):
    """EINE Schleife fuer ALLE Engines: Stuecke durchreichen, Ende garantieren,
    Fehler NACH ersten Stuecken als EIN Abbruch-Hinweis (nie still, nie doppelt).
    DLPBlockiert fliegt unverdaut durch (re-raise, kein Failover)."""
    emitted = False
    try:
        for stueck in quelle:
            emitted = True
            yield stueck
    except dlp.DLPBlockiert:
        raise
    except Exception as e:
        if not emitted:
            raise                      # vor erstem Stueck → Failover darf greifen
        yield f"\n\n[Abbruch nach Teilantwort: {e}]"   # schliesst LÜCKE 1
```
(nutzung-Übernahme + emitted-Rückgabe ans Failover – Details nach Schritt 0.)
- FERTIG-WENN: py_compiliert + Mock-Test 6.2 grün.

### Schritt 2 – Engines zu dünnen Quellen machen
- In den drei `_stream_*`: eigene Abbruch-Absicherungen um die yield-Loops entfernen (nach Schritt 0 exakt listen), Parsing/Buffer bleibt.
- call_stream Z.447-456: If-Kette → Dispatch-Tabelle `{"anthropic": ..., "gemini": ...}.get(p["typ"], _stream_openai)` und EIN `yield from strom_empfangen(...)` (Signaturen json_mode/denken_stream/kanal per Lambda je Typ). Klecks/Ollama-Zweige bleiben unverändert.
- FERTING-WENN: `code_suchen "except"` in api_manager.py zeigt im _stream_*-Bereich keine Abbruch-Behandlung mehr (nur noch Parsing-Fehler, die rausfliegen).

### Schritt 3 – Failover auf die Schleife stützen
- Z.656-690: innere for-Schleife durch `strom_empfangen` ersetzen; APIFehler/RequestException/generisch kollabieren auf EIN Muster; stilles return Z.684-688 fällt ersatzlos weg. DLPBlockiert-raise bleibt. emitted lebt NUR noch in strom_empfangen.
- FERTIG-WENN: genau EIN except-Block je Fehlersorte im gesamten Pfad.

### Schritt 4 – main.py umstellen
- Z.6961, 7915, 8591, 9755: eigene If-Ketten durch dünne `yield from api_manager.strom_empfangen(...)`-Nutzung ersetzen; Z.2878 bleibt.
- FERTIG-WENN: py_compile main.py OK; `call_stream_failover`-Aufrufe in main.py ohne drumrumliegende except-If-Ketten.

### Schritt 5 – Frontend: EINE Leseschleife (index.html)
- Neu: `async function liesStrom(res, {onZeile, signal} = {})` – genau EIN getReader, Zeilen-/Chunk-Parsing, onZeile-Callback (Format bleibt beim Aufrufer), finally garantiert Aufräum-Callback IMMER.
- Alle 7 Stellen rufen nur noch liesStrom; qwirbeln() bekommt AbortController + signal (LÜCKE 3).
- FERTIG-WENN: `code_suchen "getReader"` in index.html → GENAU 1 Treffer (in liesStrom).

### Schritt 6 – Gegenproben (MESSEN STATT ERKLÄREN)
- 6.1 python_ausfuehren: py_compile api_manager.py, main.py, agent.py → Exit 0.
- 6.2 Mock-Probelauf Backend: Generator liefert 3 Stuecke, wirft dann → Ausgabe == 3 Stuecktexte + GENAU EIN „[Abbruch…“-Hinweis, danach sauberes Ende. Gegenprobe: Wurf VOR erstem Stueck → Exception kommt raus. DLPBlockiert-Stub → re-raise.
- 6.3 Frontend: gebaut, NICHT getestet (läuft hier nicht) – der Nutzer prüft: Stop-Knopf mitten in Antwort → Spinner schließt; Fehler mit falschem Key → Teiltext bleibt + Fehlerzeile.
- 6.4 app.js neu bauen (Weg aus 0.5), sonst gilt die Änderung nur in index.html.

## Offene Punkte / Annahmen
- Z.700-1202 ungelesen → Schritt 0 holt das nach (Namen/Lagen der _stream_* aus yield-Positionen geschlossen: 820/993/1047/1157/1172/1177).
- Event-Formate der 7 Frontend-Schleifen unklar → onZeile-Callback statt festem Parser.
- Build-Weg app.js unklar → 0.5.
- agent.py Z.599 bleibt Join-Verbraucher (kein Spinner-Risiko).

---

## Anhang A – Backend-Fix (umgesetzt 27.08.2026, api_manager.py, call_stream_failover Z.684)

Vorher warf sich der KETTE-Loop bei einer UNERWARTETEN Exception (alles ausser
APIFehler/RequestException/DLPBlockiert) nach schon geflossenen Tokens still
mit `return` aus — die Antwort brach mitten im Satz ab: ohne Marker, ohne
Grund. Genau dieser Zweig verstiess gegen den Vertrag, den die anderen zwei
schon hielten (Teilantwort bleibt + Abbruch-Hinweis + Ende). Fix:

    except Exception as e:
        if emitted:
            if nutzung is not None:
                nutzung.update(versuch_nutzung)
            yield f"\n\n[Unerwarteter Fehler: {e}]"
            return
        fehler.append(f"{pid}: {e}")

Damit gilt EIN Vertrag am EINZIGEN Empfangs-Engpass (call_stream_failover ist
der Empfangspfad; call_stream verzweigt je Engine erst UNTEN in die
_stream_*-Funktionen — Stuecke/Ende/Fehler/Abbruch behandelt fuer ALLE
Engines dieselbe Schleife hier):
  * Token geflossen + Fehler -> Teilantwort bleibt, Marker haengt dran, Ende.
  * KEIN Token + Fehler -> naechster Anbieter der Kette (Failover wie gehabt).
  * dlp.DLPBlockiert -> hochwerfen, NIE wechseln (Blocken ist Absicht).

## Anhang B – Frontend: EINE Empfangs-Schleife statt 7 (offen, naechster Schritt)

frontend/index.html liest den Strom an 7 Stellen selbst (getReader-Loops):
5385, 5945 (Chat-Send), 6110 (Mind-Chat), 8434, 10631, 21014, 21274. JEDE hat
eigene If-Ketten fuer Zeilen-Typen + eigenes Fehler-/Abbruch-Handling — genau
da verschwinden Teilantworten oder bleibt der Spinner stehen, wenn EINE
Schleife ein Ende vergisst.

BAU (additiv, nichts loeschen): EINE Funktion, alle Stellen rufen sie:

    async function liesStrom(res, onZeile) {
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;                        // ENDE: Schleife endet IMMER
          buffer += decoder.decode(value, { stream: true });
          let i;
          while ((i = buffer.indexOf("\n")) >= 0) {
            const zeile = buffer.slice(0, i).trim();
            buffer = buffer.slice(i + 1);
            if (zeile) { try { onZeile(JSON.parse(zeile)); } catch (_) {} }
          }
        }
      } catch (e) {
        if (e && e.name === "AbortError") return { abbruch: true };  // ABBRUCH
        throw e;                                                     // FEHLER
      }
      const rest = buffer.trim();                  // letzte Zeile ohne \n
      if (rest) { try { onZeile(JSON.parse(rest)); } catch (_) {} }
      return { abbruch: false };
    }

Vertrag fuer onZeile (EINE Wahrheit, gilt je Engine — das Backend oben
liefert sie schon vereinheitlicht):
  {response|text|chunk|delta} -> Text an die Blase
  {denken|thinking}           -> Denk-Element
  {error|fehler}              -> Fehlerzeile in die Blase
  {done|fertig|event:"ende"}  -> Lauf beenden
Spinner-Regel: Aufrufer packt liesStrom in try/catch + FINALLY — finally
setzt setLoading(false) IMMER (auch bei Abort/Error); der bis dahin
gesammelte Teiltext bleibt in der Blase (wird NIE geleert).

Reihenfolge (Abhaengigkeit zuerst): 5945 Chat-Send zuerst (groesster
Effekt), dann 6110, 8434, 10631, 5385, 21014, 21274 — je: alte Reader-
Schleife durch liesStrom-Aufruf ersetzen, Zeilen-Ifs in EINEN onZeile-
Callback umbauen.
FERTIG WENN: code_suchen "getReader" in frontend/index.html nur noch
treffer INNERHALB von liesStrom zeigt (genau 1) — alle anderen Stellen
rufen liesStrom.

---

## Fortschritt

- [x] 27.08.2026 Schritt 1-4: Empfangsmodul GEBAUT und BEWIESEN. %USERPROFILE%\Desktop\Qwirbel\backend\stream_empfang.py (261 Zeilen) mit strom_empfangen(stueck_quelle, abbruch=None) -> Ergebnis-Objekt (dataclass) mit text + status; Status-Vokabular 'fertig' | 'fehler' | 'abgebrochen'. Selbsttest im Modul: 22/22 bestanden, exit 0 – u.a. Fehler mitten im Strom (Teilantwort bleibt vollstaendig erhalten, nicht verworfen), Abbruch vorher/nach Stuecken (terminiert IMMER mit Status – die Spinner-Garantie auf Funktionsebene), dict-Stuecke mit getrennten Gedanken, nicht-iterierbare Quelle -> status fehler, Quelle wird am Schluss geschlossen. Rein additiv: api_manager.py unangetastet.
- [ ] OFFEN (naechster Schritt): EINBINDUNG in api_manager.py call_stream_failover (Z.617-690) – die vier try/except-Zweige mit `if emitted:` durch EINEN Empfang ueber strom_empfangen ersetzen, ohne das Wort-fuer-Wort-Live-Yield zu verlieren (dazu stream_empfang.py additiv um live-Stueck-Rueckgabe erweitern). Danach Backend-Neustart, damit der Fix aktiv wird.
- [ ] OFFEN (spaeter, eigener Schritt): Frontend – 7 getReader()-Loops in frontend/index.html (Z.5385/5945/6110/8434/10631/21014/21274) auf EINE Empfangs-Schleife umstellen; Spinner-Garantie dort via finally -> setLoading(false) in jedem der drei Status.

---

## FERTIG – Schritt 8 von 8 abgeschlossen (27.08.2026, abends)

### Ergebnis

| Was | Ergebnis |
|---|---|
| EIN Empfangsmodul | `%USERPROFILE%\Desktop\Qwirbel\backend\stream_empfang.py` (351 Zeilen): `strom_weiterleiten()` (live, Ereignis-Tupel `("stueck"|"fertig"|"fehler"|"abgebrochen", wert)`) und `strom_empfangen()` (gepuffert, StromErgebnis-Dataclass mit text/status/fehler/gedanken) – EINE Schleife fuer Stuecke/Ende/Fehler/Abbruch, alle Engines. Selbsttest 26/26, exit 0. |
| Integration | `%USERPROFILE%\Desktop\Qwirbel\backend\api_manager.py` (jetzt 1222 Zeilen): `call_stream_failover` (Z.~619-710) ersetzt die Stuecke-Schleife samt der vier try/except-`if emitted`-Zweige durch `for art, wert in stream_empfang.strom_weiterleiten(quelle)`; Import ergaenzt. Der Failover entscheidet nur noch EINE Frage: floss schon ein Stueck? |
| Beweis | python_ausfuehren gegen die ECHTEN Dateien: 22/22 Pruefungen bestanden, exit 0 (erst 15/18, dann nach dem 2-Stellen-Edit komplett gruen). |

### Bewiesener Vertrag (die 22 Pruefungen)

1. Syntax beider Dateien + `import stream_empfang` wirklich ladbar (nicht nur Text).
2. EIN Codepfad: strom_weiterleiten-Schleife im Failover; alte Stuecke-Schleife UND beide except-Ketten (APIFehler/RequestException) ENTFERNT.
3. Fehler MITTEN im Strom: Teilantwort bleibt, Hinweis dahinter ("[Anbieter-Abbruch ...]"/"[Verbindung ...]"/"[Unerwarteter Fehler ...]" je Art), KEIN zweiter Anbieter, Teilverbrauch gezaehlt.
4. Fehler VOR dem ersten Stueck: Failover zum naechsten Anbieter, on_switch meldet den Wechsel, gezaehlt wird der ERFOLGREICHE.
5. Alle Anbieter tot: APIFehler-Sammelmeldung, jeder wurde versucht.
6. `dlp.DLPBlockiert`: durchgereicht statt geschluckt, kein Ersatz-Text des Failovers (Blocken ist der Sinn).
7. Abbruch (GeneratorExit des aeusseren Generators): Stueck kam live an, Quelle danach geschlossen – kein offener Spinner, keine offene Verbindung.

### Aktivwerden

Der LAUFENDE Backend-Prozess hat den alten Code noch im Speicher: Der neue Empfangspfad gilt ab dem naechsten Start von window.py. (Hinweis aus dem Schwarm: das laufende Fenster koennte die AppData-Installationskopie servieren – dann wird der Fix dort erst mit dem naechsten Update/Install aktiv.)
