mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-09 15:02:33 +02:00
feat: OpenRouter-Backend + Modell-Dropdown im Leitstand, Live-Log fuer Dauer/Tokens/Kosten
Zweites Kategorisierungs-Backend neben Ollama (pipe/categorize.py), fuer Vergleichstests mit staerkeren Cloud-Modellen. Umschaltbar per Dropdown oben rechts im Leitstand (pipe/modellwahl.py, geteilte telefon/modell.json) - der laufende Watcher braucht dafuer keinen Neustart, die Auswahl wird bei jedem Anruf frisch gelesen. Jede Kategorisierung loggt jetzt Backend/Modell/Dauer/Tokens/Kosten mit ins Leitstand-Verlauf (protokoll.schreibe), nicht nur versteckt in meta.json. Testergebnis (Nemotron ueber OpenRouter, :free-Tier): stark schwankende Latenz (8,5s bis 80s je nach Modell/Auslastung), einmal 502 "temporarily overloaded" - :free-Modelle sind fuer spaeter geplanten Echtzeit-Dialog ungeeignet, dafuer braucht es ein bezahltes/dediziertes Tier. telefon/modell.json ist Laufzeit-Zustand, kein Quellcode - neu ignoriert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
This commit is contained in:
parent
4109aee1bb
commit
3e2d829542
7 changed files with 230 additions and 18 deletions
|
|
@ -12,9 +12,16 @@ WHISPER_THREADS=8
|
|||
WHISPER_MODELL=medium
|
||||
WHISPER_COMPUTE=int8
|
||||
|
||||
# --- Kategorisierung (Ollama, lokal) ---
|
||||
# --- Kategorisierung ---
|
||||
# Backend: "ollama" (Default, lokal) oder "openrouter" (Cloud, nur zum Testen
|
||||
# ob ein staerkeres Modell besser extrahiert - Transkript verlaesst dabei den
|
||||
# Rechner, nicht im Praxisbetrieb einsetzen).
|
||||
KATEGORISIERUNG_BACKEND=ollama
|
||||
OLLAMA_URL=http://127.0.0.1:11434
|
||||
OLLAMA_MODELL=qwen3:8b
|
||||
# Nur fuer KATEGORISIERUNG_BACKEND=openrouter:
|
||||
OPENROUTER_KEY=
|
||||
OPENROUTER_MODELL=
|
||||
|
||||
# --- Nextcloud (leer lassen = nur lokale Ablage) ---
|
||||
NEXTCLOUD_URL=
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -12,3 +12,5 @@ __pycache__/
|
|||
/telefon/ansage.wav
|
||||
/telefon/verlauf.log
|
||||
/telefon/*.log
|
||||
# Laufzeit-Zustand (aktive Modell-Auswahl aus dem Leitstand-Dropdown), kein Quellcode
|
||||
/telefon/modell.json
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
"""Kategorisierung: Transkript -> strukturierte Felder (Ollama/Qwen, lokal).
|
||||
"""Kategorisierung: Transkript -> strukturierte Felder.
|
||||
|
||||
Nutzt den Chat-Endpunkt von Ollama mit erzwungenem JSON-Schema. Denk-Tokens des
|
||||
Modells landen separat im "thinking"-Feld und werden ignoriert; zurück kommt
|
||||
reines JSON.
|
||||
Zwei Backends (Auswahl siehe pipe.modellwahl):
|
||||
- "ollama" (Default): lokal, erzwungenes JSON-Schema, Denk-Tokens landen
|
||||
separat im "thinking"-Feld und werden ignoriert.
|
||||
- "openrouter": Cloud, zum Testen ob ein staerkeres Modell besser extrahiert.
|
||||
Transkript verlaesst dabei den Rechner - nur zum Testen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from functools import lru_cache
|
||||
|
||||
from . import config
|
||||
from . import config, modellwahl
|
||||
|
||||
KATEGORIEN = [
|
||||
"termin", "rezept", "ueberweisung", "befund",
|
||||
|
|
@ -51,13 +54,35 @@ class KategorisierungsFehler(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
def kategorisiere(transkript: str) -> dict:
|
||||
"""Ordnet ein Transkript ein. Wirft KategorisierungsFehler bei Problemen."""
|
||||
def kategorisiere(transkript: str) -> tuple[dict, dict]:
|
||||
"""Ordnet ein Transkript ein. Wirft KategorisierungsFehler bei Problemen.
|
||||
|
||||
Gibt (auswertung, meta) zurueck - meta fuers Live-Log im Leitstand:
|
||||
backend, modell, dauer_s, tokens, kosten_usd (letzteres nur OpenRouter,
|
||||
bei :free-Modellen 0.0).
|
||||
|
||||
Backend + Modell kommen aus pipe.modellwahl (per Leitstand-Dropdown
|
||||
umschaltbar, kein Neustart noetig) - Fallback ist config.py, wenn dort
|
||||
keine Auswahl getroffen wurde.
|
||||
"""
|
||||
if not transkript.strip():
|
||||
raise KategorisierungsFehler("Leeres Transkript kann nicht kategorisiert werden.")
|
||||
|
||||
backend, modell = modellwahl.backend_und_modell()
|
||||
start = time.monotonic()
|
||||
if backend == "openrouter":
|
||||
ergebnis, meta = _mit_openrouter(transkript, modell)
|
||||
else:
|
||||
ergebnis, meta = _mit_ollama(transkript, modell)
|
||||
meta["backend"] = backend
|
||||
meta["modell"] = modell
|
||||
meta["dauer_s"] = round(time.monotonic() - start, 2)
|
||||
return _bereinige(ergebnis), meta
|
||||
|
||||
|
||||
def _mit_ollama(transkript: str, modell: str) -> tuple[dict, dict]:
|
||||
anfrage = {
|
||||
"model": config.OLLAMA_MODELL,
|
||||
"model": modell,
|
||||
"messages": [
|
||||
{"role": "system", "content": _prompt()},
|
||||
{"role": "user", "content": f"Transkript der Sprachnachricht:\n\n{transkript}"},
|
||||
|
|
@ -85,12 +110,65 @@ def kategorisiere(transkript: str) -> dict:
|
|||
inhalt = roh.get("message", {}).get("content", "").strip()
|
||||
if not inhalt:
|
||||
raise KategorisierungsFehler("Ollama lieferte keine Antwort.")
|
||||
# eval_count/prompt_eval_count sind Ollamas Token-Zaehler - kein Standard-
|
||||
# OpenAI-Feldname, aber inhaltlich dasselbe (Antwort-/Prompt-Tokens).
|
||||
tokens = roh.get("prompt_eval_count", 0) + roh.get("eval_count", 0) or None
|
||||
try:
|
||||
ergebnis = json.loads(inhalt)
|
||||
return json.loads(inhalt), {"tokens": tokens, "kosten_usd": None}
|
||||
except json.JSONDecodeError as fehler:
|
||||
raise KategorisierungsFehler(f"Antwort war kein gültiges JSON: {inhalt[:200]}") from fehler
|
||||
|
||||
return _bereinige(ergebnis)
|
||||
|
||||
def _mit_openrouter(transkript: str, modell: str) -> tuple[dict, dict]:
|
||||
if not config.OPENROUTER_KEY:
|
||||
raise KategorisierungsFehler("OPENROUTER_KEY fehlt in .env.")
|
||||
|
||||
# OpenAI-kompatible Chat-API. json_object statt json_schema, weil nicht
|
||||
# jedes ueber OpenRouter geroutete Modell striktes Schema unterstuetzt -
|
||||
# der Systemprompt beschreibt die Felder bereits explizit in Prosa.
|
||||
# usage.include=true ist eine OpenRouter-Erweiterung: liefert zusaetzlich
|
||||
# die tatsaechlichen Kosten (USD) in der Antwort mit, nicht nur Tokens.
|
||||
anfrage = {
|
||||
"model": modell,
|
||||
"messages": [
|
||||
{"role": "system", "content": _prompt()},
|
||||
{"role": "user", "content": f"Transkript der Sprachnachricht:\n\n{transkript}"},
|
||||
],
|
||||
"temperature": 0,
|
||||
"response_format": {"type": "json_object"},
|
||||
"usage": {"include": True},
|
||||
}
|
||||
daten = json.dumps(anfrage).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
data=daten,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {config.OPENROUTER_KEY}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as antwort:
|
||||
roh = json.loads(antwort.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as fehler:
|
||||
detail = fehler.read().decode("utf-8", errors="replace")[:200]
|
||||
raise KategorisierungsFehler(f"OpenRouter-Fehler {fehler.code}: {detail}") from fehler
|
||||
except urllib.error.URLError as fehler:
|
||||
raise KategorisierungsFehler(f"OpenRouter nicht erreichbar: {fehler}") from fehler
|
||||
|
||||
try:
|
||||
inhalt = roh["choices"][0]["message"]["content"].strip()
|
||||
except (KeyError, IndexError) as fehler:
|
||||
raise KategorisierungsFehler(f"Unerwartete OpenRouter-Antwort: {roh}") from fehler
|
||||
if not inhalt:
|
||||
raise KategorisierungsFehler("OpenRouter lieferte keine Antwort.")
|
||||
nutzung = roh.get("usage") or {}
|
||||
meta = {"tokens": nutzung.get("total_tokens"), "kosten_usd": nutzung.get("cost")}
|
||||
try:
|
||||
return json.loads(inhalt), meta
|
||||
except json.JSONDecodeError as fehler:
|
||||
raise KategorisierungsFehler(f"Antwort war kein gültiges JSON: {inhalt[:200]}") from fehler
|
||||
|
||||
|
||||
def _bereinige(d: dict) -> dict:
|
||||
|
|
|
|||
|
|
@ -66,9 +66,21 @@ WHISPER_PROMPT = os.environ.get(
|
|||
WHISPER_MODELL = os.environ.get("WHISPER_MODELL", "medium")
|
||||
WHISPER_COMPUTE = os.environ.get("WHISPER_COMPUTE", "int8")
|
||||
|
||||
# --- Kategorisierung (Ollama, lokal) -----------------------------------------
|
||||
# --- Kategorisierung -----------------------------------------------------
|
||||
# Welches Modell aktiv ist, waehlt der Leitstand per Dropdown (pipe.modellwahl,
|
||||
# geteilte Zustandsdatei telefon/modell.json) - kein Neustart noetig. Hier nur
|
||||
# die Bausteine dafuer: Ollama-Zugang (lokal, Default-Auswahl) und der
|
||||
# OpenRouter-Zugang fuers Testen staerkerer Cloud-Modelle. Patientendaten
|
||||
# (Transkript) verlassen bei OpenRouter den Rechner - nur zu Testzwecken,
|
||||
# nicht im Praxisbetrieb.
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
|
||||
OLLAMA_MODELL = os.environ.get("OLLAMA_MODELL", "qwen3:8b")
|
||||
|
||||
OPENROUTER_KEY = os.environ.get("OPENROUTER_KEY", "")
|
||||
# Optional: eigenes Testmodell zusaetzlich zu den zwei fest im Dropdown
|
||||
# hinterlegten (siehe pipe/modellwahl.py) - leer lassen wenn nicht gebraucht.
|
||||
OPENROUTER_MODELL = os.environ.get("OPENROUTER_MODELL", "")
|
||||
|
||||
PROMPT_DATEI = WURZEL / "prompts" / os.environ.get(
|
||||
"PROMPT_DATEI", "categorize_de.txt"
|
||||
)
|
||||
|
|
|
|||
63
pipe/modellwahl.py
Normal file
63
pipe/modellwahl.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Aktives Kategorisierungs-Modell - umschaltbar aus dem Leitstand (Dropdown
|
||||
oben rechts), ohne .env-Aenderung oder Neustart des Watchers.
|
||||
|
||||
Der Watcher (pipe.watch, Langzeit-Prozess) und der Leitstand (pipe.server,
|
||||
eigener Prozess) laufen getrennt - die Auswahl wird deshalb in einer kleinen
|
||||
JSON-Datei geteilt, die categorize.py bei jedem Anruf frisch liest (kein
|
||||
Caching), damit eine Umschaltung sofort beim naechsten Anruf greift.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from . import config
|
||||
|
||||
DATEI = config.TELEFON / "modell.json"
|
||||
|
||||
# Feste, kuratierte Auswahl statt freier Eingabe - ein Tippfehler in einer
|
||||
# Modell-ID im Leitstand darf nicht den naechsten Anruf scheitern lassen.
|
||||
# "openrouter"-Eintraege sind zum Testen gedacht (Transkript verlaesst dabei
|
||||
# den Rechner) - nicht fuer den Praxisbetrieb.
|
||||
AUSWAHL = [
|
||||
{"id": "ollama", "label": f"{config.OLLAMA_MODELL} (lokal)",
|
||||
"backend": "ollama", "modell": config.OLLAMA_MODELL},
|
||||
{"id": "or-ultra", "label": "Nemotron Ultra 550B (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": "nvidia/nemotron-3-ultra-550b-a55b:free"},
|
||||
{"id": "or-lightning", "label": "Nemotron 3.5 Lightning (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": "nvidia/nemotron-3.5-lightning:free"},
|
||||
]
|
||||
# Optionaler vierter Eintrag zum schnellen Ausprobieren eines beliebigen
|
||||
# OpenRouter-Modells, ohne Code zu aendern - einfach OPENROUTER_MODELL in
|
||||
# .env setzen.
|
||||
if config.OPENROUTER_MODELL:
|
||||
AUSWAHL.append({"id": "or-custom", "label": f"{config.OPENROUTER_MODELL} (OpenRouter, Test)",
|
||||
"backend": "openrouter", "modell": config.OPENROUTER_MODELL})
|
||||
_STANDARD = AUSWAHL[0]["id"]
|
||||
_NACH_ID = {e["id"]: e for e in AUSWAHL}
|
||||
|
||||
|
||||
def aktuelle_id() -> str:
|
||||
"""Liest die aktive Auswahl. Unbekanntes/Fehlendes gilt als Standard."""
|
||||
try:
|
||||
wert = json.loads(DATEI.read_text(encoding="utf-8")).get("id")
|
||||
except Exception:
|
||||
return _STANDARD
|
||||
return wert if wert in _NACH_ID else _STANDARD
|
||||
|
||||
|
||||
def setze(kennung: str) -> bool:
|
||||
"""Schreibt die Auswahl. False, wenn die ID nicht vorgesehen ist."""
|
||||
if kennung not in _NACH_ID:
|
||||
return False
|
||||
try:
|
||||
DATEI.parent.mkdir(parents=True, exist_ok=True)
|
||||
DATEI.write_text(json.dumps({"id": kennung}), encoding="utf-8")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def backend_und_modell() -> tuple[str, str]:
|
||||
"""Backend + Modell-ID der aktiven Auswahl, fuer categorize.py."""
|
||||
eintrag = _NACH_ID[aktuelle_id()]
|
||||
return eintrag["backend"], eintrag["modell"]
|
||||
|
|
@ -29,7 +29,7 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None,
|
|||
|
||||
print("▸ Kategorisiere …")
|
||||
if t.text.strip():
|
||||
auswertung = categorize.kategorisiere(t.text)
|
||||
auswertung, meta = categorize.kategorisiere(t.text)
|
||||
else:
|
||||
# Kein Text erkannt: Anrufer hat nach dem Signalton nicht gesprochen
|
||||
# oder sofort aufgelegt (oder eine bekannte Whisper-Halluzination
|
||||
|
|
@ -46,13 +46,26 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None,
|
|||
"aufgelegt.",
|
||||
"stichworte": [],
|
||||
}
|
||||
print(f" Kategorie: {auswertung['kategorie']} · Dringlichkeit: {auswertung['dringlichkeit']}")
|
||||
meta = {"backend": "-", "modell": "-", "dauer_s": 0.0, "tokens": None, "kosten_usd": None}
|
||||
print(f" Kategorie: {auswertung['kategorie']} · Dringlichkeit: {auswertung['dringlichkeit']}"
|
||||
f" ({meta['modell']}, {meta['dauer_s']}s)")
|
||||
|
||||
# Dauer/Tokens/Kosten mit ins Live-Log (Leitstand-Verlauf), nicht nur in
|
||||
# den JSON-Feldern versteckt - fuer den Modell-Vergleich (Dropdown oben
|
||||
# rechts) muss man das sofort sehen, ohne meta.json aufzuklappen.
|
||||
zusatz = f"{meta['dauer_s']}s"
|
||||
if meta.get("tokens"):
|
||||
zusatz += f" · {meta['tokens']} Tok"
|
||||
if meta.get("kosten_usd"):
|
||||
zusatz += f" · ${meta['kosten_usd']:.4f}"
|
||||
protokoll.schreibe(
|
||||
"notfall" if auswertung["dringlichkeit"] == "notfall" else "anruf",
|
||||
f"{auswertung['kategorie']} · {auswertung['dringlichkeit']}"
|
||||
+ (f" · {auswertung['anrufer_name']}" if auswertung.get("anrufer_name") else ""),
|
||||
+ (f" · {auswertung['anrufer_name']}" if auswertung.get("anrufer_name") else "")
|
||||
+ f" ({meta['modell']}, {zusatz})",
|
||||
nummer=anrufer_nummer, kategorie=auswertung["kategorie"],
|
||||
dringlichkeit=auswertung["dringlichkeit"])
|
||||
dringlichkeit=auswertung["dringlichkeit"], modell=meta["modell"],
|
||||
dauer_s=meta["dauer_s"], tokens=meta.get("tokens"), kosten_usd=meta.get("kosten_usd"))
|
||||
|
||||
bekannter_kontakt = kontakte.nachschlagen(anrufer_nummer)
|
||||
if bekannter_kontakt:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from . import config, dashboard, protokoll, stapel, store
|
||||
from . import config, dashboard, modellwahl, protokoll, stapel, store
|
||||
|
||||
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
|
||||
# damit haeufiges Nachfragen den Rechner nicht belastet.
|
||||
|
|
@ -277,6 +277,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()})
|
||||
elif pfad == "/api/verlauf":
|
||||
self._json(verlauf(150))
|
||||
elif pfad == "/api/modell":
|
||||
self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.AUSWAHL})
|
||||
elif pfad.startswith("/audio/"):
|
||||
self._audio(pfad[len("/audio/"):])
|
||||
else:
|
||||
|
|
@ -292,6 +294,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||
protokoll.schreibe("archiv", f"{anzahl} Anruf(e) archiviert (simuliert)", anzahl=anzahl)
|
||||
self._json({"ok": True, "anzahl": anzahl, "simuliert": True})
|
||||
return
|
||||
if pfad == "/api/modell":
|
||||
try:
|
||||
laenge = int(self.headers.get("Content-Length") or 0)
|
||||
kennung = json.loads(self.rfile.read(min(laenge, 10_000)).decode("utf-8"))["id"]
|
||||
except Exception:
|
||||
self._json({"ok": False, "fehler": "ungültige Anfrage"})
|
||||
return
|
||||
if not modellwahl.setze(kennung):
|
||||
self._json({"ok": False, "fehler": f"unbekanntes Modell: {kennung}"})
|
||||
return
|
||||
protokoll.schreibe("system", f"Kategorisierungs-Modell → {kennung}")
|
||||
self._json({"ok": True})
|
||||
return
|
||||
if pfad != "/api/stapel":
|
||||
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
|
||||
return
|
||||
|
|
@ -386,6 +401,8 @@ font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
|||
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:12px}
|
||||
h1{font-size:19px;margin:0}
|
||||
.stand{color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums;margin-left:auto}
|
||||
.modell-select{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
|
||||
padding:6px 10px;font-size:13px;font-family:inherit;cursor:pointer;max-width:280px}
|
||||
.dienste{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:9px}
|
||||
.dienst{background:var(--karte);border:1px solid var(--rand);border-radius:9px;padding:9px 11px;
|
||||
display:flex;align-items:center;gap:9px}
|
||||
|
|
@ -471,7 +488,9 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:
|
|||
<header><h1>Anruf-Leitstand</h1>
|
||||
<button class="sicher-btn" id="sicherBtn" title="Namen, Nummern, Anliegen und Transkripte unscharf - fuer Bildschirmaufnahmen/Screenshots">🙈 Sicher-Modus</button>
|
||||
<button class="archiv-btn" id="archivBtn" title="Erledigte Anrufe vom Board raeumen (simuliert - nichts wird geloescht oder exportiert)">🗄️ Tag archivieren</button>
|
||||
<span class="stand" id="stand">…</span></header>
|
||||
<span class="stand" id="stand">…</span>
|
||||
<select class="modell-select" id="modellSelect" title="Kategorisierungs-Modell fuer neue Anrufe - Aenderung greift sofort, kein Neustart noetig"></select>
|
||||
</header>
|
||||
<div id="verlauf"></div>
|
||||
<div id="hinweise"></div>
|
||||
<div class="notfall-banner" id="notfallBanner">🚨 <span id="notfallText"></span></div>
|
||||
|
|
@ -665,6 +684,24 @@ function sicherSetzen(an){
|
|||
try{sicherSetzen(localStorage.getItem('sicherModus')==='1')}catch(e){}
|
||||
sicherBtn.onclick=()=>sicherSetzen(!document.body.classList.contains('sicher'));
|
||||
|
||||
// Kategorisierungs-Modell (Dropdown oben rechts) - Wechsel greift sofort
|
||||
// beim naechsten Anruf, kein Neustart des Watchers noetig (siehe
|
||||
// pipe/modellwahl.py, geteilte Zustandsdatei).
|
||||
async function modellLaden(){
|
||||
const d=await hole('/api/modell'); if(!d)return;
|
||||
const sel=document.getElementById('modellSelect');
|
||||
sel.innerHTML=d.optionen.map(o=>
|
||||
`<option value="${esc(o.id)}"${o.id===d.aktuell?' selected':''}>${esc(o.label)}</option>`).join('');
|
||||
}
|
||||
document.getElementById('modellSelect').onchange=async e=>{
|
||||
const label=e.target.selectedOptions[0].textContent;
|
||||
const r=await fetch('/api/modell',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({id:e.target.value})});
|
||||
const d=await r.json().catch(()=>null);
|
||||
toast(d&&d.ok?`Modell gewechselt: ${label}`:'Umschalten fehlgeschlagen');
|
||||
};
|
||||
modellLaden();
|
||||
|
||||
document.getElementById('archivBtn').onclick=async()=>{
|
||||
const btn=document.getElementById('archivBtn');
|
||||
const letzter=STAPEL[STAPEL.length-1];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue