LLM-Arbeit sichtbar machen + als Grundregel verankern

Grundregel (CLAUDE.md): So wie das System sichtbar abbricht, muss es auch
sichtbar zeigen, wenn es arbeitet. Jede LLM-Aktion = laufende Aktivität mit
Fortschritt + animiertem Indikator; nie wie ein eingefrorenes UI.

UI: prominenter LLM-Indikator beim Kategorisieren -- rotierender Spinner,
'🤖 LLM kategorisiert … Batch X/Y', Modellname, Fortschrittsbalken (bestimmt
oder Shimmer bei unbekanntem Total), Button im Lade-Zustand (' LLM arbeitet').
prefers-reduced-motion respektiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeuner 2026-06-03 21:06:36 +02:00
parent b63dad74a0
commit 20012a7d46
2 changed files with 58 additions and 12 deletions

View file

@ -58,6 +58,22 @@ Diese drei Punkte sind der Grund, warum "die KI soll sagen, wenn sie scheitert"
hier funktioniert: nicht weil das Modell Einsicht hätte, sondern weil eine
externe, prüfbare Bedingung es erzwingt.
## Sichtbarkeit der LLM-Arbeit (Grundregel)
So wie das System **sichtbar abbricht**, wenn es scheitert, muss es auch
**sichtbar zeigen**, wenn es arbeitet. Jede LLM-Aktion (die Kategorisierung)
wird in der Oberfläche als *laufende Aktivität* dargestellt: erkennbarer
Fortschritt (z. B. „LLM kategorisiert … Batch X/Y"), ein animierter Indikator,
und das auslösende Bedien-Element im Lade-Zustand. Eine LLM-Aktion darf **nie**
wie ein eingefrorenes, totes UI aussehen.
Der Grund ist derselbe wie beim Abbruch: Transparenz. Der Nutzer muss jederzeit
zwischen „arbeitet gerade" und „hängt / ist fertig" unterscheiden können -- sonst
wirkt selbst ein korrekt laufender Prozess wie ein Defekt. Das gilt für die
Web-UI (animierter Status + Fortschrittsbalken) ebenso wie für die CLI (laufende
Status-/Batch-Zeile). Deterministische Schritte (Fetch) dürfen still sein; die
LLM-Schritte nicht.
## Skills
Das Projekt nutzt zwei Skills (in `.claude/skills/`), die exakt dem Schnitt

View file

@ -162,6 +162,23 @@
}
.pending { color:var(--muted); }
/* LLM-Arbeit SICHTBAR machen (Grundregel: zeig, dass das Modell arbeitet) */
@keyframes llm-spin { to { transform:rotate(360deg); } }
@keyframes llm-shimmer { 0%{background-position:-220px 0} 100%{background-position:220px 0} }
.llm-busy {
display:flex; align-items:center; gap:var(--s4); margin-top:var(--s3);
padding:var(--s4); border-radius:var(--radius);
background:var(--brand-weak); border:1px solid #cfe3d9;
}
.llm-busy .spin { flex:none; width:20px; height:20px; border:2.5px solid #c2dccf; border-top-color:var(--brand); border-radius:50%; animation:llm-spin .8s linear infinite; }
.llm-busy .txt { flex:1; min-width:0; }
.llm-busy .txt .hl { font-weight:700; color:var(--brand-d); }
.llm-busy .txt small { display:block; color:var(--brand); opacity:.9; font-size:12px; margin-top:1px; }
.llm-busy .bar2 { height:7px; border-radius:4px; background:#cfe3d9; overflow:hidden; margin-top:7px; }
.llm-busy .bar2 > i { display:block; height:100%; background:var(--brand); border-radius:4px; transition:width .3s; }
.llm-busy.indet .bar2 > i { width:40%; background:linear-gradient(90deg,#cfe3d9,var(--brand),#cfe3d9); background-size:220px 100%; animation:llm-shimmer 1.1s linear infinite; }
@media (prefers-reduced-motion: reduce){ .llm-busy .spin{ animation:none; } .llm-busy.indet .bar2 > i{ animation:none; } }
/* Ergebnis (Stufe 2) */
#result { margin-top:var(--s8); }
.summary { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s6); align-items:baseline; color:var(--muted); font-size:14px; margin-bottom:var(--s4); }
@ -537,19 +554,37 @@ async function holeRohdaten(){
}
// ----- Stufe 2: Kategorisieren ----------------------------------------------
// Sichtbarer LLM-Arbeits-Indikator (Grundregel: zeig, dass das Modell arbeitet)
function llmBusy(titel, done, total, modell){
const indet = !total;
const pct = indet ? 0 : Math.round(100 * done / total);
return `<div class="llm-busy ${indet ? "indet" : ""}">
<div class="spin" role="status" aria-label="Das LLM arbeitet"></div>
<div class="txt">
<span class="hl">🤖 ${esc(titel)}</span>${total ? ` · <b>Batch ${done}/${total}</b>` : ""}
<small>${modell ? "Modell: " + esc(modell) : "das Sprachmodell ordnet die Angebote ein …"}</small>
<div class="bar2"><i style="width:${pct}%"></i></div>
</div>
</div>`;
}
async function kategorisiere(){
if (!ROHDA) return;
const plz = $("#plz").value.trim();
$("#kategorisieren").disabled = true;
const btn = $("#kategorisieren");
const btnText = btn.dataset.label || btn.textContent;
btn.dataset.label = btnText;
const modellName = $("#modell").value;
btn.disabled = true; btn.textContent = "⏳ LLM arbeitet …";
$("#result").hidden = true;
setStatus(`<div class="pending">Starte Kategorisierung …</div>`);
setStatus(llmBusy("LLM startet die Kategorisierung …", 0, 0, modellName));
let job;
try {
const r = await fetch(api("/api/kategorisieren"), {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({
plz, modell: $("#modell").value, anbieter: anbieter(),
plz, modell: modellName, anbieter: anbieter(),
key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined
})
});
@ -557,7 +592,8 @@ async function kategorisiere(){
if (!r.ok) throw new Error(d.detail || r.status);
job = d.job_id;
} catch (e){
setStatus(`<div class="err">${esc(e.message)}</div>`); $("#kategorisieren").disabled = false; return;
setStatus(`<div class="err">${esc(e.message)}</div>`);
btn.disabled = false; btn.textContent = btnText; return;
}
const poll = setInterval(async () => {
@ -565,17 +601,11 @@ async function kategorisiere(){
try { s = await (await fetch(api("/api/lauf/" + job))).json(); }
catch { return; }
if (s.status === "laufend"){
if (s.total){
const pct = Math.round(100 * s.done / s.total);
setStatus(`<div>Kategorisiere … Batch <b>${s.done}/${s.total}</b>
<div class="bar"><i style="width:${pct}%"></i></div></div>`);
} else {
setStatus(`<div class="pending">Kategorisiere …</div>`);
}
setStatus(llmBusy("LLM kategorisiert …", s.done || 0, s.total || 0, modellName));
return;
}
clearInterval(poll);
$("#kategorisieren").disabled = false;
btn.disabled = false; btn.textContent = btnText;
if (s.status === "fehler"){ setStatus(`<div class="err">${esc(s.fehler)}</div>`); return; }
setStatus("");
DATEN = s.ergebnis;