const { useState, useRef, useEffect } = React;
/* ===== Konstanten ===== */
const PLATFORMS = [
{ value: "short", label: "Short Video" },
{ value: "long", label: "Long Video" },
];
const LS_LAST_PROJECT = "kveditor.lastProjectId";
// Untertitel-Schriftarten (Auswahl im Settings-Panel). Für den Burn-in muss die
// Schrift auf dem Mac installiert sein — sonst greift eine Ersatzschrift.
// Nur gebündelte Fonts: jede Auswahl ist als @font-face geladen UND liegt als
// Datei im fontsdir des Burn-ins → Vorschau und Export nutzen exakt dieselbe Schrift.
// Reihenfolge = fett→schlank. Familienname muss 1:1 zu @font-face + ASS passen.
const SUB_FONT_CHOICES = [
"Montserrat Black",
"Montserrat ExtraBold",
"Montserrat SemiBold",
"Montserrat",
"Plus Jakarta Sans ExtraBold",
"DM Sans",
"DM Serif Display",
"Fraunces Black",
];
const SUB_FONT_DEFAULT = "Montserrat Black";
const SUB_FONT_SIZE_DEFAULT = 24; // = Backend DEFAULT_FONT_SIZE; vollbild-tauglich
const SUB_FONT_SIZES = [14, 16, 20, 24, 28, 32, 40, 48];
const LS_MULTICUT = "kveditor.multicutMode";
const CONTENT_TYPES = [
{ value: "tofu", label: "TOFU — kontroverse Statements (max. 15s)", short: "TOFU" },
{ value: "mofu", label: "MOFU — Insights & Takeaways (30–60s)", short: "MOFU" },
];
const POST_PROCESSING_OPTIONS = [
{ value: "segment", label: "Nur Segment-Schnitt (schnell)" },
{ value: "v52", label: "Segment + V5.2 Feinschnitt (Ähs & Pausen raus)" },
];
const SEGMENT_TYPE_LABELS = {
content: "Content",
silence: "Stille",
breath: "Atmer",
filler_word: "Füllwort",
false_start: "Fehlstart",
repetition: "Wiederholung",
};
/* ===== API helper ===== */
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body && !(body instanceof FormData)) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
} else if (body instanceof FormData) {
opts.body = body;
}
const res = await fetch(path, opts);
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.detail || `HTTP ${res.status}`);
}
return res.json();
}
/* ===== Admin-/Feedback-Ansicht =====
Zwei Ansichten aus EINER Codebasis: Kunden-Ansicht (Standard, ohne Feedback-Felder) und
Admin-Ansicht mit Feedback pro Output-Feld. Das Passwort wird SERVER-seitig geprüft
(/api/analyst/admin/verify) — im Frontend steht es nie, sonst wäre es bei öffentlich
erreichbarem Server wirkungslos. Gesammelt wird nur; kein Auto-Fix, kein Auto-Commit. */
const AdminCtx = React.createContext({ admin: false, password: "", runId: "" });
function AdminToggle({ admin, password, onLogin, onLogout }) {
const [offen, setOffen] = useState(false);
const [pw, setPw] = useState("");
const [fehler, setFehler] = useState("");
const [pruefe, setPruefe] = useState(false);
async function submit(e) {
e?.preventDefault();
setPruefe(true); setFehler("");
try {
await api("POST", "/api/analyst/admin/verify", { password: pw });
onLogin(pw); setOffen(false); setPw("");
} catch (err) {
setFehler(err.message || "Falsches Passwort");
} finally { setPruefe(false); }
}
if (admin) {
return (
ADMIN
);
}
return (
{offen && (
)}
);
}
/* Ein Feedback-Block pro Output-Feld. In der Kunden-Ansicht rendert er nichts. */
function Feedback({ field }) {
const { admin, password, runId } = React.useContext(AdminCtx);
const [verdict, setVerdict] = useState("");
const [text, setText] = useState("");
const [status, setStatus] = useState(""); // "", "gespeichert", Fehlertext
// Was zuletzt WIRKLICH gespeichert wurde. Ohne diesen Vergleich schreibt jeder Blur einen neuen
// Eintrag, auch wenn sich nichts geändert hat — und ein Klick auf den Daumen löst zuerst den Blur
// des Textfelds aus, also gleich zwei. In den Läufen vom 2026-07-29 waren dadurch von 61 Einträgen
// rund 35 Duplikate (Run 093dc5a7: 7× derselbe Text). Die Historie bleibt bewusst append-only —
// hier fallen nur die inhaltsgleichen Wiederholungen weg.
const gespeichert = React.useRef({ verdict: "", text: "" });
if (!admin || !runId) return null;
async function speichern(v, t) {
if (gespeichert.current.verdict === v && gespeichert.current.text === t) return;
try {
await api("POST", `/api/analyst/${runId}/feedback`,
{ password, field_id: field, verdict: v, text: t });
gespeichert.current = { verdict: v, text: t };
setStatus("gespeichert ✓");
setTimeout(() => setStatus(""), 1800);
} catch (e) { setStatus(e.message || "Fehler"); }
}
const daumen = (wert, symbol) => (
);
return (
{children}
);
}
function Pill({ kind, children }) {
return {children};
}
/* ===== Pipeline-Schritte ===== */
const STEPS = [
{ key: "analyze", icon: Ico.search, title: "Originalvideo wird analysiert", desc: "Segmente, Sprache & Highlights erkennen" },
{ key: "plan", icon: Ico.brain, title: "Rohschnitt wird geplant", desc: "Füllwörter, Pausen & Wiederholungen aussortieren" },
{ key: "cut", icon: Ico.scissors, title: "Rohschnitt wird erstellt", desc: "Schnitte rendern & finales Video exportieren" },
];
function fmtMs(ms) {
if (ms == null) return "—";
const s = ms / 1000;
const mm = Math.floor(s / 60);
const ss = (s - mm * 60).toFixed(1);
return mm > 0 ? `${mm}:${ss.padStart(4, "0")}` : `${s.toFixed(2)}s`;
}
/* ===== Video Analyst Seite ===== */
// Muss mit models.analyst.FORMATE übereinstimmen — die API validiert die Auswahl dagegen (422).
const FORMATE = ["Talking Head", "Reaction", "Sketch", "Tutorial", "Vlog", "Andere"];
// Anzeige in Nutzersprache, Wert = Funnel-Stufe. Reihenfolge = Funnel-Reihenfolge.
const ZIELE = [
{ wert: "TOFU", label: "Neue Menschen erreichen" },
{ wert: "MOFU", label: "Vertrauen und Expertenstatus aufbauen" },
{ wert: "BOFU", label: "Kundenanfragen gewinnen" },
];
const ZIEL_LABEL = Object.fromEntries(ZIELE.map((z) => [z.wert, z.label]));
// Zielgruppen-Abgleich: nur gefüllt, wenn eine Marken-/Zielgruppen-Datei hochgeladen wurde.
// `trifft_kern` steht bewusst NICHT in dieser Tabelle — Treffer ist der Normalfall und braucht
// keinen Hinweis. Angezeigt wird nur, was den Nutzer zum Nachdenken bringen soll.
const ABGLEICH_HINWEIS = {
teilweise: "Das Video spricht nur einen Teil deiner Zielgruppe an.",
breiteres_publikum: "Das Video spricht deutlich mehr Menschen an als deine Zielgruppe — gut für Reichweite, schwächer für Anfragen.",
andere_zielgruppe: "Das Video spricht erkennbar jemand anderen an als deine Zielgruppe.",
};
const ANALYST_FEATURES = [
{ ico: "🎯", title: "Inhaltsanalyse", desc: "Themen, Kernaussagen & Story-Struktur erkennen" },
{ ico: "🎙️", title: "Sprach-Qualität", desc: "Füllwörter, Pausen, Sprechtempo & Verständlichkeit" },
{ ico: "✂️", title: "Schnitt-Bewertung", desc: "Übergänge, Pacing & Engagement-Kurve" },
{ ico: "📊", title: "Performance-Score", desc: "Plattform-Potenzial & konkrete Optimierungstipps" },
];
// Bewertungs-Engines zum Vergleich. Gleiches Ergebnis-Layout, gleicher Bewertungsprompt —
// nur WER/WIE bewertet ändert sich.
// Die produktive Engine. Seit 2026-09-16 V3 (Vorgabe Chris): Damit sind Videoziel,
// Zielgruppen-/Strategie-Upload, die vier Ergebniskategorien, die Funnel-Einschätzung und die
// Hook-Vorschläge für JEDEN Kunden sichtbar — vorher hingen sie alle an `engine === "v3"` und
// waren damit auf die Admin-Ansicht beschränkt.
// Das Frontend nennt die Version bewusst nirgends: Der Kunde soll nicht mit „V2 vs. V3"
// konfrontiert werden. Der Backend-Parameter bleibt `v3` — Server-Vertrag stabil.
// V2 (`v2_split`) bleibt erreichbar, aber nur über den Umschalter in der Admin-Ansicht: Ohne die
// Möglichkeit, denselben Clip gegen die alte Fassung laufen zu lassen, gäbe es keine Grundlage
// mehr für die Aussage, dass V3 besser ist.
const ANALYST_ENGINE = "v3";
const ANALYST_ENGINE_ALT = "v2_split";
// Stufen für die Fortschrittsbalken. est = geschätzte Dauer in Sekunden aus echten Läufen. Die
// Labels sind bewusst neutral — sie sollen nicht verraten, welches Werkzeug im Hintergrund läuft
// (Vorgabe Chris 2026-08-10: keine „Transkription"-o.Ä.-Meldungen mehr).
const STAGE_SET = [
{ key: "transcribe", label: "Vorbereitung", est: 28 },
{ key: "quality", label: "Prüfung", est: 6 },
{ key: "evaluate", label: "Analyse & Bewertung", est: 150 },
];
// Frontend-Fortschrittstext, unabhängig vom (technisch benannten) Backend-Detail. Ein einziger
// Text für alle Phasen — der visuelle Fortschritt kommt aus StageBars.
const ANALYSE_LAEUFT = "Analyse läuft …";
// Ein Stufen-Balken pro Bearbeitungsschritt: abgeschlossene Stufen 100 %, die aktive füllt sich zeitbasiert
// (gedeckelt bei 96 %, bis die Stufe wirklich fertig ist). So gibt es pro Stufe ein „voll"-Erlebnis.
function StageBars({ stages, activePhase, phaseStartMs, tick }) {
const activeIdx = stages.findIndex((s) => s.key === activePhase);
return (
{stages.map((s, i) => {
// Abgeschlossene Stufe → nur grüner Haken. Aktive Stufe → EIN Balken. Zukünftige → nicht anzeigen.
if (activeIdx >= 0 && i < activeIdx) {
return (
);
}
function fmtSec(s) {
if (s == null) return "—";
const mm = Math.floor(s / 60);
const ss = Math.floor(s - mm * 60);
return `${mm}:${String(ss).padStart(2, "0")}`;
}
function RatingDots({ value }) {
return (
{[1, 2, 3, 4, 5].map((i) => (
))}
);
}
// Score-Chip als Aufklapper (Vorgabe Chris, 2026-09-13). Vorher stand hier ein ⓘ mit einem
// Hover-Tooltip, der nur EINE Textseite zeigte — und zwar die Kritik. Jetzt klappt der Chip auf und
// zeigt beide Seiten untereinander: „Das ist gut" und „Das kannst du besser machen".
//
// / wie die Kategorie-Aufklapper darüber: kein eigenes Klapp-JavaScript, kein
// State, und tastaturbedienbar ohne Zutun. Die dadurch entstehende Verschachtelung (Kategorie-
// Aufklapper → Chip-Aufklapper) ist unproblematisch — jedes hält seinen eigenen Zustand.
//
// Fehlt eine der beiden Seiten, fällt ihre Überschrift weg. Ein leerer Block „Das ist gut" unter
// einem 1er-Score wäre genau der Fülltext, den filtere_positiv im Backend verhindert.
// Gibt es zu einer Dimension gar keinen Text (Altlauf, score null), bleibt der Chip ein stummes
//
ohne Pfeil: Ein Aufklapper, der nichts aufzuklappen hat, ist eine Enttäuschung.
//
// `beschreibung` ist die dritte, WERTFREIE Seite und nur für `protagonist_auftreten` gedacht: Dort
// ist sie im Normalfall (keine Daten zur Person, score null) die einzige Information überhaupt.
// Unter „Das kannst du besser machen" stünde sie falsch, also steht sie ohne Überschrift oben.
function ScoreChip({ label, score, positiv, kritik, beschreibung }) {
const txt = (v) => (v == null ? "" : String(v).trim());
const lob = txt(positiv);
const mangel = txt(kritik);
const neutral = txt(beschreibung);
// ZWEI Zeilen statt einer: Label oben, Bewertung darunter. Vorher standen Label, Punkte, Wert,
// Hinweis und Pfeil nebeneinander — in der Admin-Ansicht, wo neben jeder Kachel noch ein
// Feedback-Block im selben Raster liegt, blieb fuer das Label so wenig Platz, dass sein Text
// unter den Punkten durchlief (Screenshot Chris, 2026-09-13). Untereinander passt es in jeder
// Breite, und das Label darf so lang sein, wie es sein muss.
const werte = (klapp) => (
{/* score === null heißt "nicht bewertbar" (z.B. Sprech-Hook in einem Video ohne Sprache).
Dann gar keine Punkte zeigen — 0 von 5 gefüllten Punkten liest sich wie eine 0-Wertung. */}
{score != null && }
{score != null ? `${score}/5` : "–"}
{klapp && Details}
{klapp && ▸}
);
if (!lob && !mangel && !neutral) {
return (
{label}{werte(false)}
);
}
return (
{/* „Details" und der Pfeil stehen in derselben Zeile wie die Punkte (siehe werte()):
Sie sagen einmal je Kachel, dass hier etwas drinsteckt. Nur im geschlossenen Zustand
sichtbar (CSS) — ist die Kachel offen, sieht man die Antwort ja schon. */}
{label}
{werte(true)}
{neutral &&
{neutral}
}
{lob && (
<>
Das ist gut
{lob}
>
)}
{mangel && (
<>
Das kannst du besser machen
{mangel}
>
)}
);
}
// Handlungsempfehlungen: max 3, nach Priorität; die wichtigste hervorgehoben. Ruhige Liste, kein Häkchen-Zwang.
function Handlungsempfehlungen({ steps }) {
const top = (steps || []).slice(0, 3);
return (
💡 Handlungsempfehlungen
Du musst nicht alles umsetzen — schon eine Änderung hilft.
);
}
// Ordnet den Performance-Score ermutigend ein (Label + Farbe) statt nackter Zahl.
// Der Score wird IMMER grün dargestellt — auch bei 20 von 100.
// Vorgabe Chris: Die Farbe soll nicht demotivieren. Jedes veröffentlichte Video ist besser als
// keines; wer nach dem ersten Versuch eine rote Zahl sieht, dreht das zweite womöglich nicht mehr.
// Die Zahl selbst differenziert weiterhin, ebenso der Einordnungstext — nur das Farbsignal ist
// bewusst neutralisiert. Rot bleibt dem vorbehalten, was der Reputation wirklich schaden kann;
// dafür gibt es die Warnhinweise in den Empfehlungen, nicht den Gesamtscore.
function scoreEinordnung(score) {
if (score == null) return { label: "", color: "var(--taupe)" };
const color = "var(--ok-ink)";
if (score >= 75) return { label: "Stark — weiter so", color };
if (score >= 55) return { label: "Gute Basis — mit 1–2 Änderungen richtig stark", color };
if (score >= 35) return { label: "Solide Basis — ein paar Hebel bringen viel", color };
return { label: "Guter Anfang — die Empfehlungen unten helfen am meisten", color };
}
// Welche Storyline-Bausteine erkannt wurden — eine reine Beobachtung, kein Urteil. Sie stand
// früher mit dem Kommentar in EINEM Tooltip-Text; im Aufklapper gehört sie in die wertfreie
// Zeile oben, nicht unter „Das kannst du besser machen".
function bausteineText(struktur) {
const e = struktur?.elemente;
if (!e) return "";
const order = [["hook", "Hook"], ["bridge", "Bridge"], ["mid", "Mid"], ["peak", "Peak"], ["cta", "CTA"]];
return "Bausteine: " + order.map(([k, lbl]) => `${lbl}${e[k] ? "✓" : "✗"}`).join(" · ");
}
// `probleme`/`maengel` als EIN Text für die Kritik-Seite des Aufklappers.
// Leer bleibt leer. Der frühere Platzhalter-Satz („keine Auffälligkeiten") war eine Notwendigkeit
// des Tooltips: ohne Text wäre das ⓘ gar nicht erschienen. Der Aufklapper lässt die Überschrift
// stattdessen ganz weg — ein Test hält den Platzhalter aus der Datei heraus.
function listeText(eintraege) {
return (eintraege || []).map((e) => String(e || "").trim()).filter(Boolean).join(" · ");
}
// Die Kritikseite einer Dimension mit Listen: erst die deutlichen Mängel, dann die leichten
// Hinweise. `hinweise` fehlte hier bis 2026-09-13 — in Lauf 411b3493 stand die einzige
// Verbesserungsidee zum Skript ("der letzte Satz bleibt vage") genau dort und wurde nie gezeigt,
// woraufhin Chris schrieb: "hier fehlt ein verbesserungsvorschlag. da der score eine 4/5 ist muss
// ja noch was besser gehen."
function kritikText(block) {
return listeText([...(block?.probleme || []), ...(block?.hinweise || [])]);
}
/* ===== Stufe 2: Ergebnis in vier Kategorien =====
Spiegel von KATEGORIEN in models/analyst.py — dort ist die führende Fassung. Ändert sich die
Zuordnung dort, muss sie hier nachgezogen werden; das Frontend kann sie nicht erfragen.
Reihenfolge = Reihenfolge im Video: erst der Einstieg, dann das Halten, dann das Handwerk. */
const OUTPUT_KATEGORIEN = [
{ key: "hook", titel: "Hook", sub: "die ersten Sekunden" },
{ key: "mittelteil", titel: "Mittelteil", sub: "Aufmerksamkeit halten" },
{ key: "editing", titel: "Editing", sub: "Schnitt und Untertitel" },
{ key: "auftreten", titel: "Auftreten, Bild & Ton", sub: "wie du rüberkommst" },
];
// Dimensionsname → Kategorie-Key, aufgelöst aus denselben KATEGORIEN. Gebraucht wird das nur
// für die Stärken: `betrifft` trägt den Namen der Dimension, nicht den der Kategorie.
const DIMENSION_ZU_KATEGORIE = {
sprech_hook: "hook", text_hook: "hook", visuell_hook: "hook",
spannungsbogen: "mittelteil", struktur: "mittelteil", skript: "mittelteil",
untertitel_vorhanden: "mittelteil", cta: "mittelteil",
schnitt_pacing: "editing", untertitel_gestaltung: "editing",
einblendungen: "editing", soundeffekte: "editing",
sprechqualitaet: "auftreten", visuelle_aesthetik: "auftreten", audioqualitaet: "auftreten",
protagonist_auftreten: "auftreten",
};
// Die Score-Chips einer Kategorie. `field` ist der Feedback-Name — unverändert aus dem früheren
// Sammel-Aufklapper übernommen, damit die gesammelte Feedback-Historie zusammenpasst.
// Fehlt ein Wert (Altlauf, oder vom Code als „nicht bewertbar" auf null gesetzt), zeigt
// ScoreChip von selbst „–" — hier wird deshalb nichts herausgefiltert.
//
// Je Chip ZWEI Texte statt eines: `positiv` ist das Lob-Feld der Dimension, `kritik` die
// Verbesserungsseite. Reihenfolge innerhalb einer Kategorie wie in KATEGORIEN (models/analyst.py).
//
// `kritik` liest NICHT mehr aus `kommentar`/`grund` (Stand 2026-09-13). Diese Felder beschreiben
// bei gutem Score, was gut laeuft — in Lauf 411b3493 stand dadurch sechsmal ein Lob unter der
// Ueberschrift "Das kannst du besser machen". Dimensionen mit einem eigenen Textfeld lesen jetzt
// `verbesserung`, die uebrigen `probleme` + `hinweise` (siehe kritikText).
// `?? kommentar` faengt die Altlaeufe ab, die das Feld noch nicht kennen. `??` und nicht `||`:
// Bei einem neuen Lauf ist der LEERE String die Absicht (Score 5 = nichts zu verbessern) und darf
// nicht auf den alten Kommentar zurueckfallen; `undefined` heisst dagegen "gab es damals nicht".
function kategorieChips(ev, key, ziel) {
const u = ev.untertitel || {};
if (key === "hook") {
return [
{ field: "hook.sprech", label: "🎤 Sprech-Hook", score: ev.hook?.sprech_hook_score,
positiv: ev.hook?.sprech_hook_positiv, kritik: ev.hook?.sprech_hook_verbesserung ?? ev.hook?.sprech_hook_grund },
{ field: "hook.text", label: ev.hook?.text_hook_vorhanden ? "📝 Text-Hook" : "📝 Text-Hook (fehlt)", score: ev.hook?.text_hook_score,
positiv: ev.hook?.text_hook_positiv, kritik: ev.hook?.text_hook_verbesserung ?? ev.hook?.text_hook_grund },
{ field: "hook.visuell", label: "👁 Visuelle Hook", score: ev.hook?.visuell_hook_score,
positiv: ev.hook?.visuell_hook_positiv, kritik: ev.hook?.visuell_hook_verbesserung ?? ev.hook?.visuell_hook_grund },
];
}
if (key === "mittelteil") {
return [
{ field: "spannungsbogen", label: "📈 Spannungsbogen", score: ev.spannungsbogen?.score,
positiv: ev.spannungsbogen?.positiv, kritik: ev.spannungsbogen?.verbesserung ?? ev.spannungsbogen?.kommentar },
{ field: "struktur", label: "📖 Struktur", score: ev.struktur?.score,
beschreibung: bausteineText(ev.struktur),
positiv: ev.struktur?.positiv, kritik: ev.struktur?.verbesserung ?? ev.struktur?.kommentar },
// Stufe 3 im Backend gebaut, Chip seit 2026-09-13: die inhaltliche Substanz.
{ field: "skript", label: "✍️ Skript", score: ev.skript?.score,
positiv: ev.skript?.positiv, kritik: kritikText(ev.skript) },
{ field: "untertitel", label: "💬 Untertitel vorhanden", score: u.score,
positiv: u.positiv, kritik: u.verbesserung ?? u.kommentar },
// Der CTA ist nur beim Ziel BOFU gewichtet (SCORE_GEWICHTE_JE_ZIEL in models/analyst.py).
// Bei TOFU/MOFU stünde hier ein Score, der auf das Ergebnis gar nicht einzahlt — das
// verwirrt mehr, als es hilft.
...(ziel === "BOFU" ? [{ field: "cta", label: "🎯 Call to Action", score: ev.cta?.score,
positiv: ev.cta?.positiv, kritik: ev.cta?.verbesserung ?? ev.cta?.kommentar }] : []),
];
}
if (key === "editing") {
return [
{ field: "schnitt_pacing", label: "✂️ Schnitt & Pacing", score: ev.schnitt_pacing?.score,
positiv: ev.schnitt_pacing?.positiv, kritik: ev.schnitt_pacing?.verbesserung ?? ev.schnitt_pacing?.kommentar },
{ field: "untertitel_gestaltung", label: "🔠 Untertitel-Gestaltung", score: u.gestaltung_score,
positiv: u.gestaltung_positiv, kritik: listeText(u.maengel) },
{ field: "einblendungen", label: "🖼️ Einblendungen", score: ev.einblendungen_eval?.score,
positiv: ev.einblendungen_eval?.positiv, kritik: kritikText(ev.einblendungen_eval) },
{ field: "soundeffekte", label: "🎵 Soundeffekte", score: ev.soundeffekte?.score,
positiv: ev.soundeffekte?.positiv, kritik: ev.soundeffekte?.verbesserung ?? ev.soundeffekte?.kommentar },
];
}
return [
{ field: "sprechqualitaet", label: "🎙️ Sprechqualität", score: ev.sprechqualitaet?.score,
positiv: ev.sprechqualitaet?.positiv, kritik: kritikText(ev.sprechqualitaet) },
{ field: "visuelle_aesthetik", label: "🎨 Bildqualität", score: ev.visuelle_aesthetik?.score,
positiv: ev.visuelle_aesthetik?.positiv, kritik: kritikText(ev.visuelle_aesthetik) },
{ field: "audioqualitaet", label: "🔊 Audioqualität", score: ev.audioqualitaet?.score,
positiv: ev.audioqualitaet?.positiv, kritik: kritikText(ev.audioqualitaet) },
// score ist null, solange dem Analysten keine Daten zur Person/Marke vorliegen — ScoreChip
// zeigt dann „–" statt 0 von 5 Punkten. `beschreibung` ist in genau diesem Normalfall die
// einzige Information und steht deshalb wertfrei oben im Aufklapper, nicht unter der Kritik.
{ field: "protagonist_auftreten", label: "🧍 Auftreten", score: ev.protagonist_auftreten?.score,
beschreibung: ev.protagonist_auftreten?.beschreibung,
positiv: ev.protagonist_auftreten?.positiv,
kritik: kritikText(ev.protagonist_auftreten) },
];
}
// Stärken nach Kategorie sortieren. Altläufe und v2 liefern `staerken` als Liste von Strings
// ohne `betrifft` — die landen in `ohne` und werden ohne Kategorie-Überschrift gezeigt. Ohne
// diesen Zweig verschwände bei jedem gespeicherten Altlauf das Lob komplett.
function staerkenNachKategorie(staerken) {
const gruppen = {};
const ohne = [];
for (const s of staerken || []) {
const text = (typeof s === "string" ? s : s?.text) || "";
if (!text.trim()) continue;
const kat = typeof s === "string" ? "" : (DIMENSION_ZU_KATEGORIE[s?.betrifft] || "");
if (kat) (gruppen[kat] = gruppen[kat] || []).push(text);
else ohne.push(text);
}
return { gruppen, ohne };
}
/* ===== V1.1: Markdown-Teilmenge für Chat-Antworten =====
Kein react-markdown: Das Frontend läuft ohne Build-Step (Babel im Browser), npm-Pakete gibt
es hier nicht. Unterstützt wird bewusst nur, was der Chat-Prompt erlaubt: Absätze,
"- "-Aufzählungen, "1. "-Listen und **fett**.
WICHTIG: Muss UNVOLLSTÄNDIGES Markdown vertragen. Beim Streaming ist ein ** oft noch nicht
geschlossen — ein naiver Renderer färbt dann den Rest der Antwort fett. Deshalb: nur PAARE
werden fett, ein einzelnes ** bleibt sichtbarer Text.
Kein dangerouslySetInnerHTML — der Text kommt vom Modell. */
function mdInline(text) {
const teile = [];
let rest = text;
let key = 0;
while (true) {
const auf = rest.indexOf("**");
if (auf === -1) { if (rest) teile.push(rest); break; }
const zu = rest.indexOf("**", auf + 2);
if (zu === -1) { teile.push(rest); break; } // offenes ** → als Text stehen lassen
if (auf > 0) teile.push(rest.slice(0, auf));
teile.push({rest.slice(auf + 2, zu)});
rest = rest.slice(zu + 2);
}
return teile;
}
function MarkdownLite({ text }) {
const zeilen = (text || "").split("\n");
const bloecke = [];
let liste = null; // {geordnet:bool, items:[]}
const listeSchliessen = () => {
if (!liste) return;
const Tag = liste.geordnet ? "ol" : "ul";
bloecke.push(
{liste.items.map((it, i) =>
{mdInline(it)}
)}
);
liste = null;
};
for (const zeile of zeilen) {
const auf = zeile.replace(/^\s+/, "");
const punkt = auf.match(/^[-•]\s+(.*)$/);
const zahl = auf.match(/^\d+\.\s+(.*)$/);
if (punkt) {
if (liste && liste.geordnet) listeSchliessen();
liste = liste || { geordnet: false, items: [] };
liste.items.push(punkt[1]);
} else if (zahl) {
if (liste && !liste.geordnet) listeSchliessen();
liste = liste || { geordnet: true, items: [] };
liste.items.push(zahl[1]);
} else {
listeSchliessen();
if (auf.trim()) bloecke.push(
{mdInline(zeile)}
);
}
}
listeSchliessen();
return <>{bloecke}>;
}
/* ===== V1.1: Rückfragen-Chat zur fertigen Analyse =====
Fragt nach, lässt sich erklären, bekommt konkretere Hinweise. Der Chat ÄNDERT die Bewertung
nicht — das braucht Versionierung und ein Bestätigungs-Gate und kommt separat. */
function ChatPanel({ runId, filename, dauerSec = 0 }) {
const [offen, setOffen] = useState(false);
const [nachrichten, setNachrichten] = useState([]);
const [eingabe, setEingabe] = useState("");
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState("");
const [teilantwort, setTeilantwort] = useState("");
const endeRef = useRef(null);
const feldRef = useRef(null);
// Verlauf laden, sobald eine (andere) Analyse vorliegt — nach einem Reload ist der Chat
// sonst leer, obwohl serverseitig alles noch da ist.
useEffect(() => {
if (!runId) { setNachrichten([]); return; }
let abgebrochen = false;
api("GET", `/api/analyst/${runId}/chat`)
.then((d) => { if (!abgebrochen) setNachrichten(d.nachrichten || []); })
.catch(() => {});
return () => { abgebrochen = true; };
}, [runId]);
// Immer ans Ende scrollen — auch während die Antwort noch wächst.
// Nur wenn der Chat offen ist: sonst scrollt der Browser zu einem unsichtbaren Element
// und reißt die Seite an eine Stelle, an der der Nutzer gar nicht ist.
useEffect(() => {
if (!offen) return;
endeRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [nachrichten, teilantwort, offen]);
function autoResize(el) {
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 140) + "px";
}
async function senden() {
const frage = eingabe.trim();
if (!frage || laeuft || !runId) return;
setFehler("");
setEingabe("");
if (feldRef.current) feldRef.current.style.height = "auto";
setNachrichten((n) => [...n, { rolle: "user", text: frage, ts: new Date().toISOString() }]);
setLaeuft(true);
setTeilantwort("");
try {
const res = await fetch(`/api/analyst/${runId}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ frage }),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.detail || `HTTP ${res.status}`);
}
// Stück für Stück lesen — das ist der Punkt, an dem sich „schnell" anfühlt.
// Auf die Komplettantwort zu warten würde denselben Server-Call langsam wirken lassen.
const reader = res.body.getReader();
const decoder = new TextDecoder();
let voll = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
voll += decoder.decode(value, { stream: true });
setTeilantwort(voll);
}
setNachrichten((n) => [...n, { rolle: "model", text: voll, ts: new Date().toISOString() }]);
// Verlauf einmal nachladen: Die IDs vergibt der Server beim Schreiben in chat.jsonl.
// Ohne das hätte die gerade eingetroffene Antwort keine id — und damit kein
// Bewertungsfeld, bis man die Seite neu lädt.
try {
const frisch = await api("GET", `/api/analyst/${runId}/chat`);
if (frisch.nachrichten?.length) setNachrichten(frisch.nachrichten);
} catch (_) { /* Anzeige steht schon, ohne id fehlt nur die Bewertung */ }
} catch (e) {
setFehler(e.message);
} finally {
setTeilantwort("");
setLaeuft(false);
}
}
function beiTaste(e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); senden(); }
}
const leer = nachrichten.length === 0 && !laeuft;
return (