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
|
hier funktioniert: nicht weil das Modell Einsicht hätte, sondern weil eine
|
||||||
externe, prüfbare Bedingung es erzwingt.
|
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
|
## Skills
|
||||||
|
|
||||||
Das Projekt nutzt zwei Skills (in `.claude/skills/`), die exakt dem Schnitt
|
Das Projekt nutzt zwei Skills (in `.claude/skills/`), die exakt dem Schnitt
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,23 @@
|
||||||
}
|
}
|
||||||
.pending { color:var(--muted); }
|
.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) */
|
/* Ergebnis (Stufe 2) */
|
||||||
#result { margin-top:var(--s8); }
|
#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); }
|
.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 ----------------------------------------------
|
// ----- 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(){
|
async function kategorisiere(){
|
||||||
if (!ROHDA) return;
|
if (!ROHDA) return;
|
||||||
const plz = $("#plz").value.trim();
|
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;
|
$("#result").hidden = true;
|
||||||
setStatus(`<div class="pending">Starte Kategorisierung …</div>`);
|
setStatus(llmBusy("LLM startet die Kategorisierung …", 0, 0, modellName));
|
||||||
|
|
||||||
let job;
|
let job;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(api("/api/kategorisieren"), {
|
const r = await fetch(api("/api/kategorisieren"), {
|
||||||
method:"POST", headers:{"Content-Type":"application/json"},
|
method:"POST", headers:{"Content-Type":"application/json"},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
plz, modell: $("#modell").value, anbieter: anbieter(),
|
plz, modell: modellName, anbieter: anbieter(),
|
||||||
key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined
|
key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
@ -557,7 +592,8 @@ async function kategorisiere(){
|
||||||
if (!r.ok) throw new Error(d.detail || r.status);
|
if (!r.ok) throw new Error(d.detail || r.status);
|
||||||
job = d.job_id;
|
job = d.job_id;
|
||||||
} catch (e){
|
} 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 () => {
|
const poll = setInterval(async () => {
|
||||||
|
|
@ -565,17 +601,11 @@ async function kategorisiere(){
|
||||||
try { s = await (await fetch(api("/api/lauf/" + job))).json(); }
|
try { s = await (await fetch(api("/api/lauf/" + job))).json(); }
|
||||||
catch { return; }
|
catch { return; }
|
||||||
if (s.status === "laufend"){
|
if (s.status === "laufend"){
|
||||||
if (s.total){
|
setStatus(llmBusy("LLM kategorisiert …", s.done || 0, s.total || 0, modellName));
|
||||||
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>`);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearInterval(poll);
|
clearInterval(poll);
|
||||||
$("#kategorisieren").disabled = false;
|
btn.disabled = false; btn.textContent = btnText;
|
||||||
if (s.status === "fehler"){ setStatus(`<div class="err">${esc(s.fehler)}</div>`); return; }
|
if (s.status === "fehler"){ setStatus(`<div class="err">${esc(s.fehler)}</div>`); return; }
|
||||||
setStatus("");
|
setStatus("");
|
||||||
DATEN = s.ergebnis;
|
DATEN = s.ergebnis;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue