Files
boehmitools-main/plugins/tandoor-nutrition/static/index.html
T
2026-07-24 21:37:03 +02:00

566 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>