// ClawdDotNet — Chat-Ansicht // // Leitfaden: Sicherer Chatverlauf (strikt textContent), Echtzeit-Ereignisse über SSE. import { api, holeAktiveInstanz } from "../modules/api.js"; import { abonnieren } from "../modules/events.js"; import { erstelleElement, erstelleIcon, PFADE, formatiereZeit, erstellePlakette, zeigeMeldung } from "../modules/dom.js"; let aktiverAgentId = null; let agentenListe = []; let nachrichtenContainer = null; let schrittBox = null; let eingabeTextarea = null; let sendenButton = null; let abbrechenButton = null; let agentenSelector = null; let statusPlakette = null; let entladeEreignisse = []; // #592: Zählt die Aufbauten dieser Ansicht. Ein Aufbau, der während seines await von // einem neueren überholt wurde, meldet keine Ereignisse mehr an — sonst hingen zwei // "chat"-Listener und jede Nachricht erschien doppelt. let aufbauZaehler = 0; function raeumeEreignisseAuf() { entladeEreignisse.forEach((cleanup) => cleanup()); entladeEreignisse = []; } export async function rendereChat(parameter = {}) { const container = document.getElementById("ansicht-inhalt"); if (!container) return; container.replaceChildren(); const dieserAufbau = ++aufbauZaehler; // Alte Ereignisse aufräumen raeumeEreignisseAuf(); const chatShell = erstelleElement("div", { klasse: "chat-ansicht" }); // 1. Kopfleiste der Chat-Ansicht const kopfleiste = erstelleElement("div", { klasse: "chat-kopf" }); const agentenWahl = erstelleElement("div", { klasse: "chat-agenten-wahl" }); agentenSelector = erstelleElement("select", { klasse: "eingabe-select", onchange: (e) => waehleAgent(e.target.value) }); statusPlakette = erstellePlakette("Lädt…", "neutral"); agentenWahl.append(erstelleElement("label", { text: "Agent: " }), agentenSelector, statusPlakette); const aktionen = erstelleElement("div", { klasse: "chat-aktionen" }); const weckenBtn = erstelleElement( "button", { klasse: "schaltflaeche flach", title: "Agenten manuell ausführen / wecken", onclick: weckeAktivenAgent }, erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " Wecken" }) ); const leerenBtn = erstelleElement( "button", { klasse: "schaltflaeche flach", title: "Chatverlauf dieses Agenten leeren", onclick: leereChatverlauf }, erstelleIcon(PFADE.leeren, 14), erstelleElement("span", { text: " Verlauf leeren" }) ); abbrechenButton = erstelleElement( "button", { klasse: "schaltflaeche gefahr flach", title: "Laufenden Lauf abbrechen", hidden: true, onclick: brecheChatAb }, erstelleIcon(PFADE.abbrechen, 14), erstelleElement("span", { text: " Abbrechen" }) ); aktionen.append(weckenBtn, leerenBtn, abbrechenButton); kopfleiste.append(agentenWahl, aktionen); // 2. Nachrichtenverlauf & Schrittanzeige nachrichtenContainer = erstelleElement("div", { klasse: "chat-verlauf" }); schrittBox = erstelleElement("div", { klasse: "chat-schritt-box", hidden: true }); const schrittIcon = erstelleIcon(PFADE.aktualisieren, 14, "rotierend"); const schrittText = erstelleElement("span", { klasse: "schritt-text", text: "" }); schrittBox.append(schrittIcon, schrittText); // 3. Eingabebereich const eingabeBereich = erstelleElement("div", { klasse: "chat-eingabe-bereich" }); eingabeTextarea = erstelleElement("textarea", { klasse: "chat-eingabe", placeholder: "Nachricht an den Agenten eingeben (Enter zum Senden, Umschalt+Enter für neue Zeile)…", rows: "3", onkeydown: (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendeNachricht(); } } }); sendenButton = erstelleElement( "button", { klasse: "schaltflaeche primaer", title: "Nachricht senden", onclick: sendeNachricht }, erstelleIcon(PFADE.senden, 16), erstelleElement("span", { text: "Senden" }) ); eingabeBereich.append(eingabeTextarea, sendenButton); chatShell.append(kopfleiste, nachrichtenContainer, schrittBox, eingabeBereich); container.append(chatShell); // Lade Agenten und initialisiere await ladeAgenten(parameter.agentId); // Während des Ladens neu aufgebaut? Dann gehört die Ansicht dem neueren Aufbau. if (dieserAufbau !== aufbauZaehler) return; // Falls zwischendurch doch etwas angemeldet wurde: vorher abräumen, nie doppelt. raeumeEreignisseAuf(); // SSE-Abonnements entladeEreignisse.push( abonnieren("chat", (daten) => { const aktiveId = holeAktiveInstanz(); if (daten.agentId === aktiverAgentId && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) { fuegeNachrichtEin(daten); } }) ); entladeEreignisse.push( abonnieren("step", (daten) => { const aktiveId = holeAktiveInstanz(); if (daten.agentId === aktiverAgentId && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) { zeigeSchritt(daten.text); } }) ); entladeEreignisse.push( abonnieren("run", (daten) => { const aktiveId = holeAktiveInstanz(); if (!daten.instanz || !aktiveId || daten.instanz === aktiveId) { versteckeSchritt(); aktualisiereLaufStatus(false); } }) ); entladeEreignisse.push( abonnieren("instance", (daten) => { const aktiveId = holeAktiveInstanz(); if (daten.kind === "Agents" && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) { ladeAgenten(aktiverAgentId); } }) ); } async function ladeAgenten(gewuenschteId = null) { try { agentenListe = await api("/agents"); agentenSelector.replaceChildren(); if (!agentenListe || agentenListe.length === 0) { const opt = erstelleElement("option", { text: "Keine Agenten vorhanden", value: "" }); agentenSelector.append(opt); statusPlakette.replaceWith(erstellePlakette("Keine Agenten", "warnung")); return; } for (const a of agentenListe) { const opt = erstelleElement("option", { value: a.agentId, text: `${a.displayName} (${a.agentId})` }); agentenSelector.append(opt); } const zielId = gewuenschteId && agentenListe.some((a) => a.agentId === gewuenschteId) ? gewuenschteId : agentenListe[0].agentId; agentenSelector.value = zielId; await waehleAgent(zielId); } catch (err) { zeigeMeldung(`Fehler beim Laden der Agenten: ${err.message}`, "gefahr"); } } async function waehleAgent(agentId) { aktiverAgentId = agentId; const agent = agentenListe.find((a) => a.agentId === agentId); aktualisiereLaufStatus(agent?.isRunning ?? false); // Adresse mitführen, ohne hashchange auszulösen (#592): Ein Setzen von location.hash // ließ den Router die ganze Ansicht mitten im Laden ein zweites Mal aufbauen. const ziel = `#/chat/${encodeURIComponent(agentId)}`; if (window.location.hash !== ziel) { history.replaceState(null, "", ziel); } await ladeChatVerlauf(agentId); } function aktualisiereLaufStatus(laeuft) { if (statusPlakette) { const neuePlakette = laeuft ? erstellePlakette("Beschäftigt", "warnung") : erstellePlakette("Bereit", "erfolg"); statusPlakette.replaceWith(neuePlakette); statusPlakette = neuePlakette; } if (abbrechenButton) { abbrechenButton.hidden = !laeuft; } } async function ladeChatVerlauf(agentId) { if (!nachrichtenContainer) return; nachrichtenContainer.replaceChildren( erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Verlauf…" }) ); try { const verlauf = await api(`/agents/${encodeURIComponent(agentId)}/chat`); nachrichtenContainer.replaceChildren(); if (!verlauf || verlauf.length === 0) { nachrichtenContainer.append( erstelleElement("div", { klasse: "hinweis-zentriert", text: "Noch keine Nachrichten im Verlauf vorhanden." }) ); return; } for (const eintrag of verlauf) { fuegeNachrichtEin(eintrag); } scrolleNachUnten(); } catch (err) { nachrichtenContainer.replaceChildren( erstelleElement("div", { klasse: "fehler-zentriert", text: `Verlauf konnte nicht geladen werden: ${err.message}` }) ); } } function fuegeNachrichtEin(eintrag) { // Falls aktuell der Hinweis angezeigt wird, entfernen const platzhalter = nachrichtenContainer.querySelector(".hinweis-zentriert"); if (platzhalter) platzhalter.remove(); const rolle = (eintrag.role || "user").toLowerCase(); const istUser = rolle === "user"; const istSystem = rolle === "system"; const istTool = rolle === "tool"; let rollenKlasse = "nachricht-benutzer"; let rollenTitel = "Benutzer"; if (istSystem) { rollenKlasse = "nachricht-system"; rollenTitel = "System"; } else if (istTool) { rollenKlasse = "nachricht-werkzeug"; rollenTitel = "Werkzeug"; } else if (!istUser) { rollenKlasse = "nachricht-agent"; rollenTitel = agentenListe.find((a) => a.agentId === aktiverAgentId)?.displayName || "Agent"; } const zeile = erstelleElement("div", { klasse: `chat-zeile ${istUser ? "zeile-benutzer" : "zeile-fremd"}` }); const nachrichtCard = erstelleElement("div", { klasse: `nachricht-blase ${rollenKlasse}` }); const kopf = erstelleElement("div", { klasse: "nachricht-meta" }); const autor = erstelleElement("strong", { text: rollenTitel }); const zeit = erstelleElement("span", { klasse: "nachricht-zeit", text: formatiereZeit(eintrag.timestamp) }); kopf.append(autor); if (eintrag.instanzName) { const instanzBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.instanzName }); kopf.append(instanzBadge); } if (eintrag.source) { const quelleBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.source }); kopf.append(quelleBadge); } kopf.append(zeit); // WICHTIG (Regel 3.1): Fremdtext ist NIE HTML! Strikt textContent! const textKörper = erstelleElement("div", { klasse: "nachricht-text", text: eintrag.content || "" }); nachrichtCard.append(kopf, textKörper); zeile.append(nachrichtCard); nachrichtenContainer.append(zeile); scrolleNachUnten(); } function zeigeSchritt(text) { if (!schrittBox) return; const span = schrittBox.querySelector(".schritt-text"); if (span) span.textContent = text; schrittBox.hidden = false; aktualisiereLaufStatus(true); } function versteckeSchritt() { if (schrittBox) schrittBox.hidden = true; } function scrolleNachUnten() { if (nachrichtenContainer) { nachrichtenContainer.scrollTop = nachrichtenContainer.scrollHeight; } } async function sendeNachricht() { if (!aktiverAgentId || !eingabeTextarea) return; const nachricht = eingabeTextarea.value.trim(); if (!nachricht) return; eingabeTextarea.value = ""; sendenButton.disabled = true; aktualisiereLaufStatus(true); try { await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat`, { method: "POST", body: { message: nachricht } }); } catch (err) { zeigeMeldung(`Nachricht konnte nicht gesendet werden: ${err.message}`, "gefahr"); aktualisiereLaufStatus(false); } finally { sendenButton.disabled = false; eingabeTextarea.focus(); } } async function brecheChatAb() { if (!aktiverAgentId) return; try { await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat/abort`, { method: "POST" }); versteckeSchritt(); aktualisiereLaufStatus(false); zeigeMeldung("Lauf wurde abgebrochen.", "warnung"); } catch (err) { zeigeMeldung(`Abbruch fehlgeschlagen: ${err.message}`, "gefahr"); } } async function weckeAktivenAgent() { if (!aktiverAgentId) return; try { await api(`/agents/${encodeURIComponent(aktiverAgentId)}/run`, { method: "POST" }); aktualisiereLaufStatus(true); zeigeMeldung("Agent wurde manuell gestartet.", "info"); } catch (err) { zeigeMeldung(`Starten fehlgeschlagen: ${err.message}`, "gefahr"); } } async function leereChatverlauf() { if (!aktiverAgentId) return; if (!confirm("Soll der gesamte Chatverlauf dieses Agenten unwiderruflich gelöscht werden?")) return; try { await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat`, { method: "DELETE" }); nachrichtenContainer.replaceChildren( erstelleElement("div", { klasse: "hinweis-zentriert", text: "Verlauf wurde geleert." }) ); zeigeMeldung("Chatverlauf geleert.", "info"); } catch (err) { zeigeMeldung(`Leeren fehlgeschlagen: ${err.message}`, "gefahr"); } }