chore: initial import
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Nährwerte vervollständigen
|
||||
|
||||
Findet Zutaten, bei denen Eigenschaften fehlen, lässt die Lücken schätzen und
|
||||
schreibt sie nach Prüfung zurück.
|
||||
|
||||
## Die drei Schritte
|
||||
|
||||
**1 · Prüfen** liest alle Zutaten und Eigenschaften und zeigt, wo was fehlt.
|
||||
Sortiert nach Rezeptzahl — eine Lücke bei „Zwiebel“ (18 Rezepte) wiegt
|
||||
schwerer als bei „Yuzukosho“ (0 Rezepte). Verändert nichts.
|
||||
|
||||
**2 · Vorschlagen** lässt ein Sprachmodell die fehlenden Werte je 100 g
|
||||
schätzen. Geht in Blöcken zu 20 Zutaten an OpenAI. Schreibt eine
|
||||
Vorschlagsdatei, in Tandoor passiert nichts.
|
||||
|
||||
**3 · Übernehmen** zeigt jeden Vorschlag mit Häkchen und änderbarem Wert.
|
||||
Erst „Trockenübung“, dann „Übernehmen“. Von jeder geänderten Zutat wird der
|
||||
Zustand vorher und nachher gesichert.
|
||||
|
||||
## Was die Zahlen sind — und was nicht
|
||||
|
||||
Schätzungen eines Sprachmodells. Für Hausgebrauch und Größenordnung gedacht,
|
||||
keine Laboranalyse. Wer Nährwerte medizinisch braucht, prüft sie besser gegen
|
||||
eine echte Datenbank. Deshalb ist jeder Wert vor dem Schreiben editierbar.
|
||||
|
||||
## Wie es mit Tandoor umgeht
|
||||
|
||||
* Vorhandene Werte werden **nie** überschrieben (außer man hakt es ausdrücklich an).
|
||||
* Tandoor ersetzt beim `PATCH` die komplette `properties`-Liste. Das Tool
|
||||
schickt deshalb immer den vollständigen Bestand mit — sonst fiele weg, was
|
||||
schon da war.
|
||||
* Zutaten ohne Bezugsmenge bekommen sie mitgesetzt (Vorgabe: 100 g). Ohne die
|
||||
kann Tandoor keine Rezeptwerte rechnen.
|
||||
|
||||
## Ohne Suite
|
||||
|
||||
export TANDOOR_URL=https://kitchen.example.de
|
||||
export TANDOOR_TOKEN=...
|
||||
export OPENAI_API_KEY=...
|
||||
|
||||
python3 plugins/tandoor-nutrition/tool/nutrition.py pruefen
|
||||
python3 plugins/tandoor-nutrition/tool/nutrition.py vorschlagen --limit 20
|
||||
python3 plugins/tandoor-nutrition/tool/nutrition.py uebernehmen \
|
||||
--vorschlag data/tandoor-nutrition/vorschlaege/<datei>.json --apply
|
||||
|
||||
## Dateien
|
||||
|
||||
data/tandoor-nutrition/bericht/bericht.json letzte Prüfung
|
||||
data/tandoor-nutrition/bericht/luecken.csv dieselbe als Tabelle
|
||||
data/tandoor-nutrition/vorschlaege/<zeit>.json Vorschläge
|
||||
data/tandoor-nutrition/laeufe/<zeit>/ Sicherungen je Übernahme
|
||||
|
||||
## Wenn „Vorschlagen“ nur „ohne Ergebnis“ liefert
|
||||
|
||||
Kommt ein Lauf mit 0 Vorschlägen und „N ohne Ergebnis“ zurück, ist meist die
|
||||
Modell-Abfrage grundsätzlich gescheitert, nicht an einzelnen Zutaten. Der erste
|
||||
Fehler steht jetzt in der Vorschlagsdatei (`error`) und im Übernehmen-Reiter.
|
||||
|
||||
Schnellster Weg zur Ursache: im Reiter „2 · Vorschlagen“ auf **Verbindung
|
||||
testen** (oder `nutrition.py probe`). Das macht eine einzelne Beispiel-Abfrage
|
||||
und zeigt den vollständigen Fehler. Häufige Ursachen:
|
||||
|
||||
* **Modellname** nicht vorhanden oder nicht freigeschaltet → in den
|
||||
Einstellungen ein anderes Modell setzen (`OPENAI_MODEL`).
|
||||
* **API-Key** wird abgelehnt → `OPENAI_API_KEY` prüfen.
|
||||
* **Guthaben/Kontingent** erschöpft.
|
||||
* **Aufruf-Parameter**: Neuere Modelle lehnen `temperature` ungleich dem
|
||||
Standard oder `response_format` ab. Das Tool probiert solche Fälle inzwischen
|
||||
selbst mildere Varianten durch und merkt sich die funktionierende — hier ist
|
||||
also normalerweise nichts mehr zu tun.
|
||||
@@ -0,0 +1,245 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Plugin-Adapter für „Nährwerte vervollständigen“.
|
||||
|
||||
Das Backend hält selbst keine Logik: Es startet tool/nutrition.py über den
|
||||
Job-Runner und liest die Dateien, die das Skript hinterlässt. Dadurch ist
|
||||
dasselbe Werkzeug auch ohne Suite benutzbar und die Oberfläche zeigt
|
||||
zwangsläufig das, was auch geschrieben würde.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.jobs import job_router
|
||||
from core.backups import backup_router
|
||||
|
||||
TOOL = "nutrition.py"
|
||||
|
||||
|
||||
class ScanRequest(BaseModel):
|
||||
insecure: bool = False
|
||||
|
||||
|
||||
class ProposeRequest(BaseModel):
|
||||
foods: list[int] = Field(default_factory=list)
|
||||
properties: list[int] = Field(default_factory=list)
|
||||
limit: int = Field(default=0, ge=0, le=2000)
|
||||
overwrite: bool = False
|
||||
model: str | None = None
|
||||
insecure: bool = False
|
||||
|
||||
|
||||
class ProbeRequest(BaseModel):
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class ApplyRequest(BaseModel):
|
||||
proposal: str
|
||||
apply: bool = False
|
||||
overwrite: bool = False
|
||||
continue_on_error: bool = True
|
||||
base_amount: float = Field(default=100.0, gt=0, le=10000)
|
||||
base_unit: str = Field(default="g", min_length=1, max_length=40)
|
||||
insecure: bool = False
|
||||
|
||||
|
||||
class EditRequest(BaseModel):
|
||||
"""Die in der Oberfläche geprüften Werte zurückschreiben."""
|
||||
proposals: list[dict[str, Any]]
|
||||
|
||||
|
||||
def create_app(ctx):
|
||||
tool = ctx.path("tool", TOOL)
|
||||
report_file = ctx.data_dir / "bericht" / "bericht.json"
|
||||
proposals_dir = ctx.data_dir / "vorschlaege"
|
||||
proposals_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def tool_env() -> dict:
|
||||
"""
|
||||
Umgebung für den Subprozess.
|
||||
|
||||
Wichtig: DATA_DIR ausdrücklich mitgeben. Ohne das schreibt das Skript
|
||||
seinen Rückfallpfad neben die Anwendung — im Container ist der
|
||||
schreibgeschützt, und die Oberfläche würde den Bericht nie finden.
|
||||
"""
|
||||
env = ctx.settings.tool_env()
|
||||
env["DATA_DIR"] = str(ctx.data_dir)
|
||||
return env
|
||||
|
||||
def base_argv(insecure: bool) -> list[str]:
|
||||
argv = [sys.executable, str(tool)]
|
||||
if insecure:
|
||||
argv.append("--insecure")
|
||||
return argv
|
||||
|
||||
def resolve_proposal(name: str) -> Path:
|
||||
candidate = proposals_dir / Path(name).name
|
||||
if candidate.suffix != ".json" or not candidate.is_file():
|
||||
raise HTTPException(404, f"Vorschlagsdatei „{name}“ gibt es nicht.")
|
||||
return candidate
|
||||
|
||||
def guard() -> None:
|
||||
if ctx.jobs.running(ctx.id):
|
||||
raise HTTPException(409, "Es läuft bereits etwas. Bitte abwarten.")
|
||||
if not ctx.settings.status()["tandoor"]:
|
||||
raise HTTPException(400, "Tandoor-URL und Token fehlen – siehe Einstellungen.")
|
||||
|
||||
app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def index():
|
||||
return FileResponse(ctx.path("static", "index.html"))
|
||||
|
||||
@app.get("/api/state")
|
||||
def state() -> dict[str, Any]:
|
||||
report = None
|
||||
if report_file.is_file():
|
||||
try:
|
||||
report = json.loads(report_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
report = None
|
||||
running = [j.info() for j in ctx.jobs.running(ctx.id)]
|
||||
files = sorted(
|
||||
(p.name for p in proposals_dir.glob("*.json")), reverse=True
|
||||
)
|
||||
return {
|
||||
"report": report,
|
||||
"proposals": files,
|
||||
"tandoor": ctx.settings.status()["tandoor"],
|
||||
"openai": ctx.settings.status()["openai"],
|
||||
"model": ctx.settings.get("OPENAI_MODEL") or "gpt-5.5",
|
||||
"running": running[0] if running else None,
|
||||
"data_dir": str(ctx.data_dir),
|
||||
}
|
||||
|
||||
@app.get("/api/proposals/{name}")
|
||||
def read_proposal(name: str) -> dict[str, Any]:
|
||||
return json.loads(resolve_proposal(name).read_text(encoding="utf-8"))
|
||||
|
||||
@app.post("/api/proposals/{name}")
|
||||
def save_proposal(name: str, request: EditRequest) -> dict[str, Any]:
|
||||
"""
|
||||
Speichert die in der Oberfläche geprüften und ggf. korrigierten Werte.
|
||||
|
||||
Es werden nur Häkchen und Zahlen übernommen — welche Zutat und welche
|
||||
Eigenschaft gemeint ist, bleibt das, was das Skript geschrieben hat.
|
||||
"""
|
||||
file = resolve_proposal(name)
|
||||
payload = json.loads(file.read_text(encoding="utf-8"))
|
||||
by_id = {p["food_id"]: p for p in payload.get("proposals", [])}
|
||||
|
||||
for edited in request.proposals:
|
||||
original = by_id.get(edited.get("food_id"))
|
||||
if original is None:
|
||||
continue
|
||||
original["accept"] = bool(edited.get("accept", True))
|
||||
werte = edited.get("values") or {}
|
||||
clean: dict[str, float] = {}
|
||||
for key, value in werte.items():
|
||||
if key in original["values"]:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 <= number <= 100000:
|
||||
clean[key] = round(number, 2)
|
||||
if clean:
|
||||
original["values"] = clean
|
||||
|
||||
file.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
angehakt = sum(1 for p in payload["proposals"] if p.get("accept", True))
|
||||
return {"ok": True, "accepted": angehakt, "total": len(payload["proposals"])}
|
||||
|
||||
@app.post("/api/run/scan")
|
||||
async def run_scan(request: ScanRequest) -> dict[str, Any]:
|
||||
guard()
|
||||
job = await ctx.jobs.start(
|
||||
plugin=ctx.id, label="Prüfen: fehlende Nährwerte",
|
||||
argv=base_argv(request.insecure) + ["pruefen"],
|
||||
cwd=ctx.data_dir, env=tool_env(),
|
||||
)
|
||||
return job.info()
|
||||
|
||||
@app.post("/api/run/propose")
|
||||
async def run_propose(request: ProposeRequest) -> dict[str, Any]:
|
||||
guard()
|
||||
if not ctx.settings.status()["openai"]:
|
||||
raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.")
|
||||
|
||||
argv = base_argv(request.insecure) + ["vorschlagen"]
|
||||
if request.foods:
|
||||
argv += ["--foods", ",".join(str(int(i)) for i in request.foods)]
|
||||
if request.properties:
|
||||
argv += ["--properties", ",".join(str(int(i)) for i in request.properties)]
|
||||
if request.limit:
|
||||
argv += ["--limit", str(request.limit)]
|
||||
if request.overwrite:
|
||||
argv.append("--overwrite")
|
||||
if request.model:
|
||||
argv += ["--model", request.model]
|
||||
|
||||
scope = f"{len(request.foods)} Zutaten" if request.foods else "alle Lücken"
|
||||
job = await ctx.jobs.start(
|
||||
plugin=ctx.id, label=f"Vorschlagen: {scope}", argv=argv,
|
||||
cwd=ctx.data_dir, env=tool_env(),
|
||||
)
|
||||
return job.info()
|
||||
|
||||
@app.post("/api/run/probe")
|
||||
async def run_probe(request: ProbeRequest) -> dict[str, Any]:
|
||||
# Kein Tandoor nötig — nur OpenAI. Eine einzelne Testabfrage.
|
||||
if ctx.jobs.running(ctx.id):
|
||||
raise HTTPException(409, "Es läuft bereits etwas.")
|
||||
if not ctx.settings.status()["openai"]:
|
||||
raise HTTPException(400, "OpenAI-Key fehlt – siehe Einstellungen.")
|
||||
argv = [sys.executable, str(tool), "probe"]
|
||||
if request.model:
|
||||
argv += ["--model", request.model]
|
||||
job = await ctx.jobs.start(
|
||||
plugin=ctx.id, label="OpenAI-Verbindung testen", argv=argv,
|
||||
cwd=ctx.data_dir, env=tool_env(),
|
||||
)
|
||||
return job.info()
|
||||
|
||||
@app.post("/api/run/apply")
|
||||
async def run_apply(request: ApplyRequest) -> dict[str, Any]:
|
||||
guard()
|
||||
file = resolve_proposal(request.proposal)
|
||||
argv = base_argv(request.insecure) + ["uebernehmen", "--vorschlag", str(file)]
|
||||
if request.apply:
|
||||
argv.append("--apply")
|
||||
if request.overwrite:
|
||||
argv.append("--overwrite")
|
||||
if request.continue_on_error:
|
||||
argv.append("--continue-on-error")
|
||||
argv += ["--base-amount", str(request.base_amount),
|
||||
"--base-unit", request.base_unit]
|
||||
|
||||
job = await ctx.jobs.start(
|
||||
plugin=ctx.id,
|
||||
label=("Übernehmen" if request.apply else "Trockenübung") + f": {file.stem}",
|
||||
argv=argv, cwd=ctx.data_dir, env=tool_env(),
|
||||
)
|
||||
return job.info()
|
||||
|
||||
def restore_argv(run: str, apply: bool, force: bool) -> list[str]:
|
||||
argv = [sys.executable, str(tool), "zurueck", "--lauf", run]
|
||||
if apply:
|
||||
argv.append("--apply")
|
||||
if force:
|
||||
argv.append("--force")
|
||||
return argv
|
||||
|
||||
app.include_router(backup_router(ctx, "laeufe", restore_argv))
|
||||
app.include_router(job_router(ctx.jobs, ctx.id))
|
||||
return app
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "tandoor-nutrition",
|
||||
"name": "Nährwerte vervollständigen",
|
||||
"summary": "Fehlende Zutaten-Eigenschaften finden, schätzen, übernehmen",
|
||||
"description": "Zeigt, bei welchen Zutaten Ballaststoffe, Proteine, Fett, Kohlenhydrate, Kalorien oder Zucker fehlen, lässt die Lücken je 100 g schätzen und schreibt sie nach Prüfung zurück. Rezept-Nährwerte rechnet Tandoor danach selbst.",
|
||||
"icon": "🥑",
|
||||
"category": "Tandoor",
|
||||
"version": "1.0.0",
|
||||
"entrypoint": "backend:create_app",
|
||||
"order": 50,
|
||||
"requires": ["tandoor", "openai"],
|
||||
"features": [
|
||||
"Abdeckung je Eigenschaft auf einen Blick",
|
||||
"Lücken zuerst dort, wo viele Rezepte dranhängen",
|
||||
"Jeder Wert vor dem Schreiben prüf- und änderbar",
|
||||
"Setzt fehlende Bezugsmengen gleich mit"
|
||||
],
|
||||
"docs": "TOOL-README.md"
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nährwerte vervollständigen</title>
|
||||
<link rel="stylesheet" href="/shared/boehmi.css">
|
||||
<script src="/shared/boehmi.js"></script>
|
||||
<script src="/shared/boehmi-runner.js"></script>
|
||||
<script src="/shared/boehmi-backups.js"></script>
|
||||
<style>
|
||||
/* Nur Layout – alles Optische kommt aus /shared/boehmi.css */
|
||||
.facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; }
|
||||
.fact { background: var(--bt-surface); border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg); padding: 13px 14px; box-shadow: var(--bt-shadow); }
|
||||
.fact b { display: block; font-size: 24px; font-weight: 850; letter-spacing: -.03em; }
|
||||
.fact span { font-size: 10.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--bt-muted); }
|
||||
.fact.warn b { color: var(--bt-warn); }
|
||||
.fact.ok b { color: var(--bt-ok); }
|
||||
|
||||
.coverage { display: grid; gap: 9px; }
|
||||
.cov { display: grid; grid-template-columns: 130px 1fr 92px; gap: 10px; align-items: center; font-size: 13px; }
|
||||
.cov .nm { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.track { height: 9px; border-radius: 999px; background: var(--bt-bg-2); border: 1px solid var(--bt-line-soft); overflow: hidden; }
|
||||
.fill { height: 100%; background: var(--bt-accent); border-radius: 999px; transition: width .4s ease; }
|
||||
.fill.low { background: var(--bt-err); }
|
||||
.fill.mid { background: var(--bt-warn); }
|
||||
.cov .n { text-align: right; font-family: var(--bt-mono); font-size: 11.5px; color: var(--bt-muted); }
|
||||
|
||||
.matrix { overflow: auto; max-height: 62vh; border: 1px solid var(--bt-line); border-radius: var(--bt-r); }
|
||||
table.mx { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
|
||||
table.mx th { position: sticky; top: 0; z-index: 2; background: var(--bt-surface-2); padding: 8px 7px; text-align: left; font-size: 10.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--bt-muted); border-bottom: 1px solid var(--bt-line); white-space: nowrap; }
|
||||
table.mx td { padding: 6px 7px; border-bottom: 1px solid var(--bt-line-soft); white-space: nowrap; }
|
||||
table.mx tr:hover td { background: var(--bt-surface-2); }
|
||||
td.v { text-align: right; font-family: var(--bt-mono); font-size: 11.5px; }
|
||||
td.v.gap { background: var(--bt-err-soft); color: var(--bt-err); font-weight: 700; }
|
||||
td.v.gap::after { content: "fehlt"; font-size: 9.5px; }
|
||||
.rname { font-weight: 600; max-width: 230px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rn { text-align: right; font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
|
||||
|
||||
.prop { display: grid; grid-template-columns: 26px 1fr auto; gap: 9px; align-items: center; padding: 9px 10px; border-bottom: 1px solid var(--bt-line-soft); }
|
||||
.prop:last-child { border-bottom: none; }
|
||||
.prop .pv { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.pv label { display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--bt-muted); }
|
||||
.pv input { width: 74px; padding: 4px 6px; font-size: 11.5px; font-family: var(--bt-mono); text-align: right; }
|
||||
.plist { max-height: 54vh; overflow: auto; border: 1px solid var(--bt-line); border-radius: var(--bt-r); }
|
||||
.runbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.chips { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.chip { cursor: pointer; user-select: none; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--bt-line); background: var(--bt-surface); font-size: 12px; font-weight: 650; }
|
||||
.chip.on { background: var(--bt-accent-soft); border-color: var(--bt-accent); color: var(--bt-accent); }
|
||||
.chip input { display: none; }
|
||||
@media (max-width: 900px) { .facts { grid-template-columns: repeat(2, 1fr); } .cov { grid-template-columns: 96px 1fr 72px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body data-bt-title="Nährwerte" data-bt-icon="🥑">
|
||||
|
||||
<main class="bt-main wide">
|
||||
<div class="bt-pagehead">
|
||||
<h1>Nährwerte vervollständigen</h1>
|
||||
<p>Findet Zutaten, bei denen Eigenschaften fehlen, lässt die Lücken
|
||||
schätzen und schreibt sie nach Prüfung zurück. Rezept-Nährwerte rechnet
|
||||
Tandoor danach selbst aus.</p>
|
||||
</div>
|
||||
|
||||
<div id="warn" class="bt-notice err bt-hidden"></div>
|
||||
|
||||
<div class="bt-tabs" id="tabs">
|
||||
<button class="bt-tab active" data-tab="pruefen">1 · Prüfen</button>
|
||||
<button class="bt-tab" data-tab="vorschlagen">2 · Vorschlagen</button>
|
||||
<button class="bt-tab" data-tab="uebernehmen">3 · Übernehmen</button>
|
||||
<button class="bt-tab" data-tab="sicherungen">Sicherungen</button>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ 1 Prüfen -->
|
||||
<section id="tab-pruefen" class="tab">
|
||||
<div class="bt-card">
|
||||
<div class="bt-row" style="align-items:center">
|
||||
<div style="flex:1">
|
||||
<h2 style="margin:0">Bestandsaufnahme</h2>
|
||||
<p class="hint" style="margin:4px 0 0">Liest alle Zutaten und Eigenschaften. Verändert nichts.</p>
|
||||
</div>
|
||||
<button class="primary" id="scan">Jetzt prüfen</button>
|
||||
</div>
|
||||
<div id="scanStatus" class="bt-status bt-hidden">Bereit.</div>
|
||||
<div id="scanLog" class="bt-log bt-hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="reportBox" class="bt-empty">Noch nicht geprüft. Oben auf „Jetzt prüfen“.</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════════════════════════════════════════════ 2 Vorschlagen -->
|
||||
<section id="tab-vorschlagen" class="tab bt-hidden">
|
||||
<div class="bt-card">
|
||||
<h2>Was soll geschätzt werden?</h2>
|
||||
<p class="hint">Ein Sprachmodell schätzt die fehlenden Werte je 100 g.
|
||||
Nach Tandoor geschrieben wird hier noch nichts.</p>
|
||||
|
||||
<label>Eigenschaften</label>
|
||||
<div class="chips" id="propChips"></div>
|
||||
|
||||
<label>Zutaten</label>
|
||||
<div class="chips">
|
||||
<span class="chip on" id="modeGaps" data-mode="gaps">nur Lücken</span>
|
||||
<span class="chip" id="modeUsed" data-mode="used">nur Lücken in benutzten Zutaten</span>
|
||||
<span class="chip" id="modeSel" data-mode="sel">Auswahl unten</span>
|
||||
</div>
|
||||
|
||||
<div class="bt-grid2">
|
||||
<div>
|
||||
<label for="limit">Höchstens so viele Zutaten (0 = alle)</label>
|
||||
<input type="number" id="limit" value="0" min="0" max="2000">
|
||||
</div>
|
||||
<div>
|
||||
<label for="model">Modell</label>
|
||||
<input type="text" id="model" value="">
|
||||
</div>
|
||||
</div>
|
||||
<label class="bt-check" style="margin-top:8px">
|
||||
<input type="checkbox" id="overwrite"> Auch vorhandene Werte neu schätzen
|
||||
</label>
|
||||
|
||||
<div class="runbar" style="margin-top:12px">
|
||||
<button class="primary" id="propose">Vorschläge holen</button>
|
||||
<button class="ghost" id="probe">Verbindung testen</button>
|
||||
<span class="bt-spacer"></span>
|
||||
<span class="bt-badge" id="costHint"></span>
|
||||
</div>
|
||||
<p class="hint" style="margin:8px 0 0">„Verbindung testen“ macht eine
|
||||
einzelne Beispiel-Abfrage und zeigt, ob das Modell erreichbar ist und
|
||||
antwortet — nützlich, wenn ein Lauf „ohne Ergebnis“ zurückkommt.</p>
|
||||
<div id="propStatus" class="bt-status bt-hidden">Bereit.</div>
|
||||
<div id="propLog" class="bt-log bt-hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="bt-card">
|
||||
<h2>Zutaten auswählen</h2>
|
||||
<div class="listhead bt-row" style="margin-bottom:9px">
|
||||
<input type="search" id="foodSearch" placeholder="Zutat suchen …" style="flex:1">
|
||||
<button class="mini ghost" id="selAllGaps">Alle mit Lücken</button>
|
||||
<button class="mini ghost" id="selNone">Keine</button>
|
||||
<span class="bt-badge accent" id="selCount">0</span>
|
||||
</div>
|
||||
<div class="matrix" id="foodPick"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════ 3 Übernehmen -->
|
||||
<section id="tab-uebernehmen" class="tab bt-hidden">
|
||||
<div class="bt-card">
|
||||
<div class="bt-row" style="align-items:flex-end">
|
||||
<div style="flex:1">
|
||||
<label for="proposalFile">Vorschlagsdatei</label>
|
||||
<select id="proposalFile"></select>
|
||||
</div>
|
||||
<button class="ghost" id="reload">Neu laden</button>
|
||||
</div>
|
||||
<p class="hint" id="proposalMeta" style="margin:8px 0 0"></p>
|
||||
</div>
|
||||
|
||||
<div class="bt-notice warn">
|
||||
Die Werte sind <b>Schätzungen eines Sprachmodells</b>, keine Laboranalysen.
|
||||
Für Hausgebrauch und Größenordnung gedacht. Bitte vor dem Übernehmen
|
||||
durchsehen — falsche Zahlen fallen später kaum noch auf.
|
||||
</div>
|
||||
|
||||
<div class="bt-card">
|
||||
<div class="bt-row" style="align-items:center;margin-bottom:8px">
|
||||
<h2 style="margin:0;flex:1">Vorschläge prüfen</h2>
|
||||
<button class="mini ghost" id="ackAll">Alle anhaken</button>
|
||||
<button class="mini ghost" id="ackNone">Keine</button>
|
||||
<span class="bt-badge accent" id="ackCount">0</span>
|
||||
</div>
|
||||
<div class="plist" id="proposalList"></div>
|
||||
|
||||
<div class="bt-grid2" style="margin-top:12px">
|
||||
<div>
|
||||
<label for="baseAmount">Bezugsmenge</label>
|
||||
<input type="number" id="baseAmount" value="100" min="1" step="1">
|
||||
</div>
|
||||
<div>
|
||||
<label for="baseUnit">Bezugseinheit (muss es in Tandoor geben)</label>
|
||||
<input type="text" id="baseUnit" value="g">
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Wird nur bei Zutaten gesetzt, die noch keine Bezugsmenge haben.</p>
|
||||
|
||||
<div class="runbar" style="margin-top:12px">
|
||||
<button id="dry">Trockenübung</button>
|
||||
<button class="primary" id="apply">Werte übernehmen</button>
|
||||
<span class="bt-spacer"></span>
|
||||
<button class="ghost mini" id="cancel" disabled>Abbrechen</button>
|
||||
</div>
|
||||
<div id="applyStatus" class="bt-status">Bereit.</div>
|
||||
<div id="applyLog" class="bt-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════ Sicherungen -->
|
||||
<section id="tab-sicherungen" class="tab bt-hidden"></section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let state = {};
|
||||
let report = null;
|
||||
let proposal = null;
|
||||
const picked = new Set();
|
||||
const activeProps = new Set();
|
||||
let mode = "gaps";
|
||||
|
||||
/* ------------------------------------------------------------ Reiter */
|
||||
$("tabs").addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button[data-tab]");
|
||||
if (!b) return;
|
||||
document.querySelectorAll("#tabs button").forEach((x) => x.classList.remove("active"));
|
||||
document.querySelectorAll("main > .tab").forEach((x) => x.classList.add("bt-hidden"));
|
||||
b.classList.add("active");
|
||||
$(`tab-${b.dataset.tab}`).classList.remove("bt-hidden");
|
||||
if (b.dataset.tab === "sicherungen") backupsView.reload();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------- Läufe (3 Bereiche) */
|
||||
function makeRunner(endpoint, logId, statusId, after) {
|
||||
return BT.Runner({
|
||||
endpoint,
|
||||
log: $(logId), status: $(statusId),
|
||||
onStart: () => { $(logId).classList.remove("bt-hidden"); $(statusId).classList.remove("bt-hidden"); },
|
||||
onFinish: (info) => {
|
||||
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
|
||||
info.status === "done" ? "ok" : "err");
|
||||
if (after) after(info);
|
||||
},
|
||||
});
|
||||
}
|
||||
const scanRunner = makeRunner("/api/run/scan", "scanLog", "scanStatus", () => load());
|
||||
const propRunner = makeRunner("/api/run/propose", "propLog", "propStatus", () => load());
|
||||
const probeRunner = makeRunner("/api/run/probe", "propLog", "propStatus");
|
||||
const applyRunner = makeRunner("/api/run/apply", "applyLog", "applyStatus", () => { $("cancel").disabled = true; });
|
||||
|
||||
/* ------------------------------------------------------ Sicherungen */
|
||||
const restoreRunner = BT.Runner({
|
||||
endpoint: "/api/run/restore",
|
||||
onFinish: (info) => {
|
||||
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
|
||||
info.status === "done" ? "ok" : "err");
|
||||
backupsView.reload();
|
||||
load();
|
||||
},
|
||||
});
|
||||
const backupsView = BT.Backups({
|
||||
mount: $("tab-sicherungen"),
|
||||
runner: restoreRunner,
|
||||
detail: true,
|
||||
note: `Zurückspielen setzt die Nährwerte auf den Stand vor dem Lauf —
|
||||
genau die Werte, die dieser Lauf gesetzt hat, verschwinden wieder.
|
||||
Was du seither von Hand geändert hast, wird erkannt und übersprungen.`,
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------- 1 · Bericht */
|
||||
function renderReport() {
|
||||
if (!report) return;
|
||||
const s = report.summary;
|
||||
const types = report.types;
|
||||
|
||||
$("reportBox").className = "";
|
||||
$("reportBox").innerHTML = `
|
||||
<div class="facts">
|
||||
<div class="fact"><b>${s.foods}</b><span>Zutaten</span></div>
|
||||
<div class="fact ok"><b>${s.percent_complete}%</b><span>vollständig</span></div>
|
||||
<div class="fact warn"><b>${s.incomplete}</b><span>mit Lücken</span></div>
|
||||
<div class="fact warn"><b>${s.incomplete_used_in_recipes}</b><span>Lücken in benutzten Zutaten</span></div>
|
||||
</div>
|
||||
|
||||
<section class="bt-card">
|
||||
<h2>Abdeckung je Eigenschaft</h2>
|
||||
<div class="coverage">${report.per_type.map((t) => {
|
||||
const cls = t.percent < 34 ? "low" : t.percent < 67 ? "mid" : "";
|
||||
return `<div class="cov">
|
||||
<span class="nm" title="${BT.escape(t.name)}">${BT.escape(t.name)}</span>
|
||||
<span class="track"><span class="fill ${cls}" style="width:${t.percent}%"></span></span>
|
||||
<span class="n">${t.percent}% · ${t.missing} offen</span>
|
||||
</div>`;
|
||||
}).join("")}</div>
|
||||
${s.without_base_amount ? `<p class="hint" style="margin-top:10px">
|
||||
${s.without_base_amount} Zutaten haben keine Bezugsmenge. Ohne die
|
||||
(z. B. „pro 100 g“) kann Tandoor keine Rezeptwerte rechnen — Schritt 3
|
||||
setzt sie mit.</p>` : ""}
|
||||
</section>
|
||||
|
||||
<section class="bt-card">
|
||||
<div class="bt-row" style="align-items:center;margin-bottom:9px">
|
||||
<h2 style="margin:0;flex:1">Wo genau fehlt was</h2>
|
||||
<input type="search" id="mxSearch" placeholder="filtern …" style="max-width:220px">
|
||||
<label class="bt-check"><input type="checkbox" id="onlyGaps" checked> nur Lücken</label>
|
||||
</div>
|
||||
<div class="matrix">
|
||||
<table class="mx">
|
||||
<thead><tr>
|
||||
<th>Zutat</th><th style="text-align:right">Rezepte</th>
|
||||
${types.map((t) => `<th style="text-align:right">${BT.escape(t.name)}${t.unit ? ` <span style="opacity:.6">${BT.escape(t.unit)}</span>` : ""}</th>`).join("")}
|
||||
</tr></thead>
|
||||
<tbody id="mxBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
const draw = () => {
|
||||
const q = ($("mxSearch").value || "").toLowerCase();
|
||||
const only = $("onlyGaps").checked;
|
||||
const rows = report.foods.filter((r) =>
|
||||
(!only || !r.complete) && (!q || r.name.toLowerCase().includes(q)));
|
||||
$("mxBody").innerHTML = rows.slice(0, 400).map((r) => `
|
||||
<tr>
|
||||
<td class="rname" title="${BT.escape(r.name)}">${BT.escape(r.name)}</td>
|
||||
<td class="rn">${r.numrecipe}</td>
|
||||
${types.map((t) => {
|
||||
const v = r.values[String(t.id)];
|
||||
return v === null || v === undefined
|
||||
? `<td class="v gap"></td>`
|
||||
: `<td class="v">${Number(v).toLocaleString("de-DE")}</td>`;
|
||||
}).join("")}
|
||||
</tr>`).join("") || `<tr><td colspan="${types.length + 2}" style="padding:22px;text-align:center;color:var(--bt-muted)">Nichts gefunden.</td></tr>`;
|
||||
};
|
||||
$("mxSearch").addEventListener("input", draw);
|
||||
$("onlyGaps").addEventListener("change", draw);
|
||||
draw();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- 2 · Vorschlagen */
|
||||
function renderPropChips() {
|
||||
if (!report) { $("propChips").innerHTML = `<span class="hint">Erst prüfen.</span>`; return; }
|
||||
if (!activeProps.size) report.types.forEach((t) => activeProps.add(t.id));
|
||||
$("propChips").innerHTML = report.types.map((t) =>
|
||||
`<span class="chip ${activeProps.has(t.id) ? "on" : ""}" data-prop="${t.id}">${BT.escape(t.name)}</span>`
|
||||
).join("");
|
||||
}
|
||||
$("propChips").addEventListener("click", (e) => {
|
||||
const c = e.target.closest("[data-prop]");
|
||||
if (!c) return;
|
||||
const id = Number(c.dataset.prop);
|
||||
activeProps.has(id) ? activeProps.delete(id) : activeProps.add(id);
|
||||
renderPropChips(); updateCost();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-mode]").forEach((c) => c.addEventListener("click", () => {
|
||||
mode = c.dataset.mode;
|
||||
document.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x === c));
|
||||
updateCost();
|
||||
}));
|
||||
|
||||
function targetFoods() {
|
||||
if (!report) return [];
|
||||
if (mode === "sel") return report.foods.filter((f) => picked.has(f.id));
|
||||
if (mode === "used") return report.foods.filter((f) => !f.complete && f.numrecipe > 0);
|
||||
return report.foods.filter((f) => !f.complete);
|
||||
}
|
||||
|
||||
function updateCost() {
|
||||
const n = targetFoods().length;
|
||||
const limit = Number($("limit").value) || 0;
|
||||
const eff = limit ? Math.min(n, limit) : n;
|
||||
$("costHint").textContent = eff
|
||||
? `${eff} Zutaten · etwa ${Math.ceil(eff / 20)} Anfragen`
|
||||
: "nichts ausgewählt";
|
||||
$("propose").disabled = !eff || !state.openai;
|
||||
}
|
||||
$("limit").addEventListener("input", updateCost);
|
||||
|
||||
function renderFoodPick() {
|
||||
if (!report) return;
|
||||
const q = ($("foodSearch").value || "").toLowerCase();
|
||||
const rows = report.foods.filter((f) => !q || f.name.toLowerCase().includes(q));
|
||||
$("foodPick").innerHTML = `<table class="mx"><thead><tr>
|
||||
<th style="width:30px"></th><th>Zutat</th><th style="text-align:right">Rezepte</th><th style="text-align:right">fehlt</th>
|
||||
</tr></thead><tbody>${rows.slice(0, 400).map((f) => `
|
||||
<tr>
|
||||
<td><input type="checkbox" data-food="${f.id}" ${picked.has(f.id) ? "checked" : ""}></td>
|
||||
<td class="rname">${BT.escape(f.name)}</td>
|
||||
<td class="rn">${f.numrecipe}</td>
|
||||
<td class="rn">${f.missing.length ? f.missing.length + " von " + report.types.length : "—"}</td>
|
||||
</tr>`).join("")}</tbody></table>`;
|
||||
$("selCount").textContent = picked.size;
|
||||
}
|
||||
$("foodPick").addEventListener("change", (e) => {
|
||||
const b = e.target.closest("[data-food]");
|
||||
if (!b) return;
|
||||
const id = Number(b.dataset.food);
|
||||
b.checked ? picked.add(id) : picked.delete(id);
|
||||
$("selCount").textContent = picked.size;
|
||||
updateCost();
|
||||
});
|
||||
$("foodSearch").addEventListener("input", renderFoodPick);
|
||||
$("selAllGaps").addEventListener("click", () => {
|
||||
report.foods.filter((f) => !f.complete).forEach((f) => picked.add(f.id));
|
||||
mode = "sel";
|
||||
document.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x.dataset.mode === "sel"));
|
||||
renderFoodPick(); updateCost();
|
||||
});
|
||||
$("selNone").addEventListener("click", () => { picked.clear(); renderFoodPick(); updateCost(); });
|
||||
|
||||
$("scan").addEventListener("click", () => scanRunner.start({}));
|
||||
$("propose").addEventListener("click", () => {
|
||||
const foods = mode === "sel" ? [...picked] : [];
|
||||
propRunner.start({
|
||||
foods,
|
||||
properties: [...activeProps],
|
||||
limit: Number($("limit").value) || 0,
|
||||
overwrite: $("overwrite").checked,
|
||||
model: $("model").value.trim() || null,
|
||||
});
|
||||
});
|
||||
$("probe").addEventListener("click", () => {
|
||||
$("propLog").classList.remove("bt-hidden");
|
||||
$("propStatus").classList.remove("bt-hidden");
|
||||
probeRunner.start({ model: $("model").value.trim() || null });
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------- 3 · Übernehmen */
|
||||
async function loadProposal(name) {
|
||||
if (!name) { proposal = null; $("proposalList").innerHTML = ""; $("proposalMeta").textContent = ""; return; }
|
||||
proposal = await BT.api(`/api/proposals/${encodeURIComponent(name)}`);
|
||||
const d = new Date(proposal.created_at);
|
||||
$("proposalMeta").textContent =
|
||||
`${proposal.proposals.length} Vorschläge · Modell ${proposal.model} · ${d.toLocaleString("de-DE")}` +
|
||||
(proposal.failed?.length ? ` · ${proposal.failed.length} ohne Ergebnis` : "");
|
||||
renderProposal();
|
||||
}
|
||||
|
||||
function emptyProposalNotice() {
|
||||
const n = proposal?.failed?.length || 0;
|
||||
const err = proposal?.error;
|
||||
if (!n && !err) {
|
||||
return `<div class="bt-empty" style="border:none">Diese Datei enthält keine Vorschläge.</div>`;
|
||||
}
|
||||
return `<div class="bt-notice warn" style="margin:0">
|
||||
<b>Diese Datei enthält keine übernehmbaren Vorschläge${n ? ` (${n} ohne Ergebnis)` : ""}.</b>
|
||||
Das deutet auf ein grundsätzliches Problem mit der Modell-Abfrage hin, nicht
|
||||
auf einzelne Zutaten.
|
||||
${err ? `<br><br>Erster Fehler der API:<br><code>${BT.escape(err)}</code>` : ""}
|
||||
<br><br>Prüf über <b>2 · Vorschlagen → „Verbindung testen“</b>, ob das Modell
|
||||
erreichbar ist und antwortet. Häufige Ursachen: falscher oder nicht
|
||||
freigeschalteter Modellname, fehlendes OpenAI-Guthaben, oder ein Modell, das
|
||||
bestimmte Aufruf-Parameter ablehnt.</div>`;
|
||||
}
|
||||
|
||||
function renderProposal() {
|
||||
if (!proposal) return;
|
||||
const names = Object.fromEntries(proposal.types.map((t) => [String(t.id), t]));
|
||||
$("proposalList").innerHTML = proposal.proposals.map((p) => `
|
||||
<div class="prop" data-row="${p.food_id}">
|
||||
<input type="checkbox" data-ack="${p.food_id}" ${p.accept !== false ? "checked" : ""}>
|
||||
<div>
|
||||
<b style="font-size:13px">${BT.escape(p.food_name)}</b>
|
||||
<span class="bt-muted" style="font-size:11px"> · ${p.numrecipe} Rezepte</span>
|
||||
</div>
|
||||
<div class="pv">
|
||||
${Object.entries(p.values).map(([tid, v]) => `
|
||||
<label>${BT.escape(names[tid]?.name || tid)}
|
||||
<input type="number" step="0.01" min="0" data-food="${p.food_id}" data-type="${tid}" value="${v}">
|
||||
<span>${BT.escape(names[tid]?.unit || "")}</span>
|
||||
</label>`).join("")}
|
||||
</div>
|
||||
</div>`).join("") || emptyProposalNotice();
|
||||
updateAck();
|
||||
}
|
||||
function updateAck() {
|
||||
const n = document.querySelectorAll("[data-ack]:checked").length;
|
||||
$("ackCount").textContent = `${n} angehakt`;
|
||||
$("apply").disabled = !n || !state.tandoor;
|
||||
$("dry").disabled = !n;
|
||||
}
|
||||
$("proposalList").addEventListener("change", updateAck);
|
||||
$("ackAll").addEventListener("click", () => {
|
||||
document.querySelectorAll("[data-ack]").forEach((c) => c.checked = true); updateAck();
|
||||
});
|
||||
$("ackNone").addEventListener("click", () => {
|
||||
document.querySelectorAll("[data-ack]").forEach((c) => c.checked = false); updateAck();
|
||||
});
|
||||
$("proposalFile").addEventListener("change", (e) => loadProposal(e.target.value));
|
||||
$("reload").addEventListener("click", () => load());
|
||||
|
||||
async function saveEdits() {
|
||||
const rows = proposal.proposals.map((p) => {
|
||||
const values = {};
|
||||
document.querySelectorAll(`input[data-food="${p.food_id}"][data-type]`).forEach((i) => {
|
||||
values[i.dataset.type] = i.value;
|
||||
});
|
||||
return {
|
||||
food_id: p.food_id,
|
||||
accept: $(`[data-ack="${p.food_id}"]`)?.checked ?? true,
|
||||
values,
|
||||
};
|
||||
});
|
||||
return BT.api(`/api/proposals/${encodeURIComponent($("proposalFile").value)}`,
|
||||
{ method: "POST", body: JSON.stringify({ proposals: rows }) });
|
||||
}
|
||||
|
||||
async function runApply(apply) {
|
||||
try {
|
||||
const saved = await saveEdits();
|
||||
if (!saved.accepted) { BT.toast("Nichts angehakt.", "err"); return; }
|
||||
if (apply && !confirm(
|
||||
`${saved.accepted} Zutaten in Tandoor mit geschätzten Nährwerten füllen?\n\n` +
|
||||
`Vorhandene Werte bleiben unangetastet. Von jeder geänderten Zutat wird ` +
|
||||
`vorher eine Sicherung ins Laufverzeichnis geschrieben.`)) return;
|
||||
$("cancel").disabled = false;
|
||||
applyRunner.start({
|
||||
proposal: $("proposalFile").value,
|
||||
apply,
|
||||
overwrite: false,
|
||||
continue_on_error: true,
|
||||
base_amount: Number($("baseAmount").value) || 100,
|
||||
base_unit: $("baseUnit").value.trim() || "g",
|
||||
});
|
||||
} catch (err) { BT.toast(err.message, "err"); }
|
||||
}
|
||||
$("dry").addEventListener("click", () => runApply(false));
|
||||
$("apply").addEventListener("click", () => runApply(true));
|
||||
$("cancel").addEventListener("click", () => applyRunner.cancel());
|
||||
|
||||
/* ------------------------------------------------------------- Laden */
|
||||
async function load() {
|
||||
state = await BT.api("/api/state");
|
||||
report = state.report;
|
||||
|
||||
const fehlt = [];
|
||||
if (!state.tandoor) fehlt.push("Tandoor-URL und Token");
|
||||
if (!state.openai) fehlt.push("OpenAI-Key");
|
||||
$("warn").classList.toggle("bt-hidden", !fehlt.length);
|
||||
if (fehlt.length) {
|
||||
$("warn").innerHTML = `Es fehlt: ${fehlt.join(" und ")}. Bitte in den
|
||||
<a href="/settings">Einstellungen</a> hinterlegen.`;
|
||||
}
|
||||
$("scan").disabled = !state.tandoor;
|
||||
if (!$("model").value) $("model").value = state.model;
|
||||
|
||||
const sel = $("proposalFile");
|
||||
const previous = sel.value;
|
||||
sel.innerHTML = state.proposals.map((f) =>
|
||||
`<option value="${BT.escape(f)}">${BT.escape(f.replace(".json", ""))}</option>`).join("");
|
||||
if (state.proposals.length) {
|
||||
sel.value = state.proposals.includes(previous) ? previous : state.proposals[0];
|
||||
await loadProposal(sel.value);
|
||||
} else {
|
||||
$("proposalList").innerHTML = `<div class="bt-empty" style="border:none">Noch keine Vorschläge. Schritt 2.</div>`;
|
||||
}
|
||||
|
||||
if (report) { renderReport(); renderPropChips(); renderFoodPick(); }
|
||||
updateCost(); updateAck();
|
||||
|
||||
if (state.running) {
|
||||
const lbl = state.running.label || "";
|
||||
const r = lbl.startsWith("Prüfen") ? scanRunner
|
||||
: lbl.startsWith("Vorschlagen") ? propRunner
|
||||
: lbl.startsWith("OpenAI-Verbindung") ? probeRunner
|
||||
: lbl.startsWith("Zurückspielen") || lbl.startsWith("Trockenübung Zurück") ? restoreRunner
|
||||
: applyRunner;
|
||||
if (r === probeRunner) { $("propLog").classList.remove("bt-hidden"); $("propStatus").classList.remove("bt-hidden"); }
|
||||
r.attach(state.running.id);
|
||||
}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user