Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js
T
RichardandClaude Opus 5 6581fd0187 Befunde aus dem Testbetrieb auf der Linux-VM behoben (#572)
- #578: Abgebrochene Laeufe (Schrittgrenze, Zeitueberschreitung, Abbruch,
  Ausnahme) tragen ihre bezahlten Token - vorher 0 in RunUsage, fuer das
  Budget unsichtbar.
- #580: WebFetch prueft die aufgeloeste Zieladresse beim Verbinden; ein
  erlaubter Name, der auf 127.0.0.1 aufloest, wird gesperrt.
- #575: Dateien entstehen unter Unix mit 0600 (AtomicFile, Protokolle,
  FileRW-Anhaengen, state.db), Protokollverzeichnisse mit 0700.
- #576: SIGTERM/SIGINT beenden auch mit Fenster geordnet.
- #593: OpenRouter-Statusdienst wird gestartet.
- #589: Auftragsnachricht nennt aktuelle Zeit, Zeitzone und Termin.
- #581: Unvollstaendiges Argument-JSON erreicht das Werkzeug nicht mehr;
  das Modell bekommt eine verstaendliche Rueckmeldung statt Parsertext.
- #591: Weboberflaeche liest die Felder, die API und Ereignisstrom liefern
  (Freigaben, Protokoll, Taskboard mit Termin, Werkzeug-Einstellungen,
  Tokennutzung); Ereignisstrom in camelCase.
- #592: Chat zeigt Nachrichten nicht mehr doppelt.
- #536 (teilweise): A2A-Antworten tragen Absender und Empfaenger, Parser
  wirft nicht mehr; Datums- und Volltextfilter in Agenten-Chats.
- deploy/publish.py: Packager-Pfad net10.0, Schalter --agent-file.
- Version 0.1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:40:56 +02:00

427 lines
12 KiB
JavaScript

// 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");
}
}