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:
parent
b63dad74a0
commit
20012a7d46
2 changed files with 58 additions and 12 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue