Instanzmanager Stufe 4: eine Adresse, ein Zugang, alle Instanzen (#528, E-M5)

Der Managerprozess. ClawdDotNet.Instanzmanager ist eine Konsolenanwendung:
Lizenz pruefen, uebernehmen was laeuft, Weboberflaeche aufhaengen, warten.
Beim Herunterfahren beendet er keine Instanz.

Zwei neue Projekte. ClawdDotNet.Manager.Web traegt die Weboberflaeche -
ohne jeden Verweis auf ClawdDotNet.Web, denn dort haengt App und daran
sechzehn Werkzeugprojekte. ClawdDotNet.ManagerApp ist der Einstiegspunkt.
wwwroot ist aus der Instanz in den Manager gezogen.

DER SCHNITT DER HTTP-FLAECHE: Unter /api/verwaltung steht, was den Manager
angeht - auflisten, anlegen, starten, anhalten. Alles andere unter /api
geht unveraendert an eine Instanz. Er leitet weiter, er implementiert
nicht. Welche Instanz gemeint ist, sagt die Kopfzeile X-Instanz; laeuft
genau eine, ist sie gemeint, und laufen mehrere, wird nachgefragt statt
geraten - eine Anfrage an die falsche Instanz ist schlimmer als eine
abgelehnte.

Der zusammengefuehrte Ereignisstrom haelt je laufender Instanz eine
Zuleitung und schreibt an jedes Ereignis, von wem es kommt. Ergaenzt statt
verpackt: Ein Umschlag waere einfacher gewesen, haette aber jede Ansicht
der Weboberflaeche zum Umschreiben gezwungen.

ABNAHME gegen laufende Prozesse: 401 ohne Token, Oberflaeche ohne Token
200, Instanz ueber den Manager gestartet und angehalten, /api/info und
/api/status unverfaelscht durchgereicht, /events/logs mit
"instanz":"Instance-Test" an jeder Zeile. Manager abgeschossen: Instanz
laeuft weiter. Manager neu gestartet: "1 Instanzen bekannt, 1 laufen
bereits" - uebernommen. E-M6 damit in beide Richtungen belegt.

DREI DEFEKTE, die erst der Lauf gezeigt hat:

1. Der Manager hing an einer Konsolenabfrage nach dem Lizenzschluessel. Ein
   Test auf Console.IsInputRedirected reicht dafuer NICHT: Er beantwortet
   "ist die Eingabe umgeleitet", nicht "kann jemand antworten" - ein
   Prozess ganz ohne Konsole, der gefaehrlichste Fall, ist nicht
   umgeleitet. Der Manager fragt jetzt gar nicht.
2. Eine unlesbare manager.json wurde stillschweigend durch Vorgaben
   ersetzt: falsches Instanzverzeichnis, anderer Port, Lizenzfrage - und
   nichts sagte, woran es lag. LoadProblem meldet den Grund jetzt.
3. Der Instanzprozess erbte CLAWD_CONFIG_DIR vom Manager und legte sich in
   dessen Konfigurationsverzeichnis eine eigene Settings.json an. Eine
   Umgebungsvariable vererbt sich, ein Argument nicht - der Manager nimmt
   sein Verzeichnis jetzt ueber --config.

SharedLoggerProvider ist nach Core gezogen: Es gibt jetzt zwei Webhosts,
die dasselbe brauchen, und sie duerfen einander nicht kennen.

NOCH OFFEN in dieser Stufe: Die Weboberflaeche kennt noch keine Instanzen -
Instanzauswahl, X-Instanz und die Verwaltungsansicht sind Arbeit an
wwwroot. Und webServerPort steht noch in der Instanz.

Build ohne Warnungen, 775 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 23:19:49 +02:00
co-authored by Claude Opus 5
parent cb56175d20
commit a511480b77
38 changed files with 1352 additions and 7 deletions
+1 -1
View File
@@ -161,7 +161,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable
// Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen.
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
builder.Logging.AddProvider(new Core.Logging.SharedLoggerProvider(loggerFactory));
// ASP.NET protokolliert auf Information jede einzelne Anfrage - mit eigener
// Zeile fuer Start und Ende, und das auch fuer jede Schriftart und jedes
@@ -1,25 +0,0 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Web;
/// <summary>
/// Reicht die Protokollierung des Webhosts an die Protokollierung der Anwendung durch.
///
/// <para>Ohne diese Zwischenschicht gäbe es zwei Möglichkeiten, und beide wären falsch:
/// Entweder der Webhost bekommt eigene Ausgabewege — dann stünde die Hälfte der
/// Meldungen woanders als die andere. Oder man reicht ihm die <c>ILoggerFactory</c> der
/// Anwendung direkt — dann räumt er sie beim Herunterfahren mit ab, und der Rest der
/// Anwendung protokolliert ab da ins Leere.</para>
///
/// <para>Deshalb: durchreichen, und beim Abräumen <b>nichts</b> tun. Die Fabrik gehört
/// dem <c>AppHost</c>, und der räumt sie selbst ab.</para>
/// </summary>
internal sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => shared.CreateLogger(categoryName);
public void Dispose()
{
// Absicht. Siehe Klassenbeschreibung.
}
}
File diff suppressed because it is too large Load Diff
-178
View File
@@ -1,178 +0,0 @@
// ClawdDotNet — WebUI Haupteinstiegspunkt
//
// Initialisiert Authentifizierung, Router, Ereignisstrom, Farbschema und Kopfzeile.
import { api, hatToken, loescheToken, beiUnberechtigt } from "./modules/api.js";
import { verbindeStrom, trenneStrom, beiVerbindungsStatus, abonnieren } from "./modules/events.js";
import { initialisiereTheme, wechsleTheme } from "./modules/theme.js";
import { registriereRoute, initialisiereRouter, navigiere } from "./modules/router.js";
import { initialisiereLogin, zeigeAnmeldung, versteckeAnmeldung } from "./views/login.js";
// Ansichten
import { rendereChat } from "./views/chat.js";
import { rendereAgenten } from "./views/agents.js";
import { rendereAufgaben } from "./views/tasks.js";
import { rendereDienste } from "./views/services.js";
import { rendereStaging, holeWartendeAnzahl } from "./views/staging.js";
import { rendereVerbrauch } from "./views/usage.js";
import { rendereProtokoll, trenneLogStream } from "./views/logs.js";
import { rendereInfo } from "./views/info.js";
import { rendereSicherung, rendereEinstellungen, rendereAgentenChats } from "./views/placeholders.js";
// ─── 1. Routen registrieren ───
registriereRoute("/chat", () => { trenneLogStream(); return rendereChat(); });
registriereRoute("/chat/:agentId", (p) => { trenneLogStream(); return rendereChat(p); });
registriereRoute("/agenten", () => { trenneLogStream(); return rendereAgenten(); });
registriereRoute("/agenten/:agentId", (p) => { trenneLogStream(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { trenneLogStream(); return rendereAufgaben(); });
registriereRoute("/dienste", () => { trenneLogStream(); return rendereDienste(); });
registriereRoute("/staging", () => { trenneLogStream(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { trenneLogStream(); return rendereVerbrauch(); });
registriereRoute("/agenten-chats", () => { trenneLogStream(); return rendereAgentenChats(); });
registriereRoute("/protokoll", () => rendereProtokoll());
registriereRoute("/sicherung", () => { trenneLogStream(); return rendereSicherung(); });
registriereRoute("/einstellungen", () => { trenneLogStream(); return rendereEinstellungen(); });
registriereRoute("/info", () => { trenneLogStream(); return rendereInfo(); });
// ─── 2. DOM-Verdrahtung ───
const seitenleiste = document.getElementById("seitenleiste");
const sidebarToggle = document.getElementById("sidebar-toggle");
const mobilMenueBtn = document.getElementById("mobil-menue-btn");
const seitenleisteBackdrop = document.getElementById("seitenleiste-backdrop");
const themeSchalter = document.getElementById("theme-schalter");
const abmeldenBtn = document.getElementById("abmelden-btn");
const verbindungPunkt = document.getElementById("verbindung-punkt");
const kopfInstanz = document.getElementById("kopf-instanz");
const kopfOpenRouter = document.getElementById("kopf-openrouter-status");
// Seitenleiste kollabieren (Desktop)
sidebarToggle?.addEventListener("click", () => {
seitenleiste?.classList.toggle("kollabiert");
});
// Mobil-Menü öffnen
mobilMenueBtn?.addEventListener("click", () => {
seitenleiste?.classList.add("mobil-offen");
if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = false;
});
// Mobil-Menü schließen bei Klick auf Backdrop oder Link
function schliesseMobilMenue() {
seitenleiste?.classList.remove("mobil-offen");
if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = true;
}
seitenleisteBackdrop?.addEventListener("click", schliesseMobilMenue);
document.querySelectorAll(".nav-link").forEach((link) => {
link.addEventListener("click", () => {
if (window.innerWidth <= 768) schliesseMobilMenue();
});
});
// Farbschema umschalten
themeSchalter?.addEventListener("click", () => {
wechsleTheme();
});
// Abmelden
abmeldenBtn?.addEventListener("click", () => {
loescheToken();
trenneStrom();
trenneLogStream();
zeigeAnmeldung();
});
// Bei unberechtigtem API-Aufruf (401) sofort zur Anmeldung springen
beiUnberechtigt(() => {
trenneStrom();
trenneLogStream();
zeigeAnmeldung();
});
// Verbindungsstatus des Ereignisstroms
beiVerbindungsStatus((istOffen) => {
if (!verbindungPunkt) return;
if (istOffen) {
verbindungPunkt.classList.add("offen");
verbindungPunkt.title = "Ereignisstrom: verbunden";
} else {
verbindungPunkt.classList.remove("offen");
verbindungPunkt.title = "Ereignisstrom: getrennt";
}
});
// ─── 3. Start & Initialisierung ───
initialisiereTheme();
initialisiereLogin(starteHauptAnwendung);
async function aktualisiereKopfStatus() {
try {
const status = await api("/status");
if (kopfOpenRouter && status) {
kopfOpenRouter.textContent = status.creditsText
? `OpenRouter: ${status.creditsText}`
: (status.isApiReachable ? "OpenRouter: OK" : "OpenRouter: offline");
}
} catch {
if (kopfOpenRouter) kopfOpenRouter.textContent = "";
}
}
async function aktualisiereStagingZaehler() {
const anzahl = await holeWartendeAnzahl();
const badge = document.getElementById("staging-badge");
if (badge) {
if (anzahl > 0) {
badge.textContent = String(anzahl);
badge.hidden = false;
} else {
badge.hidden = true;
}
}
}
async function starteHauptAnwendung(info) {
versteckeAnmeldung();
if (kopfInstanz && info) {
kopfInstanz.textContent = `${info.instanceName} (${info.instanceId})`;
}
// Ereignisstrom verbinden
verbindeStrom();
// Status & Zähler laden
await aktualisiereKopfStatus();
await aktualisiereStagingZaehler();
// Ereignisse für Kopf-Updates
abonnieren("status", () => {
aktualisiereKopfStatus();
});
abonnieren("instance", (daten) => {
if (daten.kind === "Settings") {
aktualisiereKopfStatus();
}
});
// Router initialisieren
initialisiereRouter();
}
// Prüfe bestehende Session
if (hatToken()) {
api("/info")
.then((info) => {
starteHauptAnwendung(info);
})
.catch(() => {
loescheToken();
zeigeAnmeldung();
});
} else {
zeigeAnmeldung();
}
Binary file not shown.
-93
View File
@@ -1,93 +0,0 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-172
View File
@@ -1,172 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ClawdDotNet</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<!-- ─── Anmelde-Ansicht ─── -->
<div id="anmeldung">
<div class="karte anmelde-kasten">
<div class="karte-kopf">
<h2>ClawdDotNet</h2>
</div>
<p class="hinweis">
Bitte gib das Zugangstoken der Instanz ein (<code>webUiToken</code> in der Instanzdatei).
</p>
<form id="anmeldeformular" class="formular-raster">
<input type="password" id="token-eingabe" placeholder="Zugangstoken (mind. 20 Zeichen)" autocomplete="current-password" required>
<button type="submit" class="primaer">Anmelden</button>
<p id="anmeldefehler" class="fehler" hidden></p>
</form>
</div>
</div>
<!-- ─── Hauptanwendung ─── -->
<div id="anwendung" hidden>
<div id="seitenleiste-backdrop" class="seitenleiste-backdrop" hidden></div>
<!-- Seitenleiste / Navigation -->
<aside id="seitenleiste" class="seitenleiste">
<div class="seitenleiste-kopf">
<span class="marke">ClawdDotNet</span>
<button id="sidebar-toggle" class="schaltflaeche-icon flach" title="Seitenleiste ein-/ausklappen" type="button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
</div>
<nav class="seitenleiste-inhalt">
<div class="nav-gruppe">
<div class="nav-gruppe-titel">Arbeiten</div>
<a href="#/chat" class="nav-link" title="Chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
<span class="nav-label">Chat</span>
</a>
<a href="#/agenten" class="nav-link" title="Agenten">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="nav-label">Agenten</span>
</a>
<a href="#/aufgaben" class="nav-link" title="Aufgaben">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span class="nav-label">Aufgaben</span>
</a>
<a href="#/dienste" class="nav-link" title="Dienste">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M4 14a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6z M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z M12 6v6 M8 8h.01 M16 8h.01"/>
</svg>
<span class="nav-label">Dienste</span>
</a>
</div>
<div class="nav-gruppe">
<div class="nav-gruppe-titel">Analyse</div>
<a href="#/verbrauch" class="nav-link" title="Tokennutzung">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7"/>
</svg>
<span class="nav-label">Tokennutzung</span>
</a>
<a href="#/agenten-chats" class="nav-link" title="Agenten-Chats">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M8 12h.01M12 12h.01M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z"/>
</svg>
<span class="nav-label">Agenten-Chats</span>
</a>
</div>
<div class="nav-gruppe">
<div class="nav-gruppe-titel">System</div>
<a href="#/staging" class="nav-link" title="Freigaben">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4"/>
</svg>
<span class="nav-label">Freigaben</span>
<span id="staging-badge" class="nav-badge" hidden>0</span>
</a>
<a href="#/protokoll" class="nav-link" title="Protokoll">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6"/>
</svg>
<span class="nav-label">Protokoll</span>
</a>
<a href="#/sicherung" class="nav-link" title="Sicherung">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8"/>
</svg>
<span class="nav-label">Sicherung</span>
</a>
<a href="#/einstellungen" class="nav-link" title="Einstellungen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="nav-label">Einstellungen</span>
</a>
<a href="#/info" class="nav-link" title="Info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
<span class="nav-label">Info</span>
</a>
</div>
</nav>
</aside>
<!-- Hauptinhalt -->
<div class="hauptbereich">
<header class="kopfzeile">
<div class="kopfzeile-links">
<button id="mobil-menue-btn" class="schaltflaeche-icon flach" title="Menü öffnen" type="button">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<span id="kopf-instanz" class="gedaempft"></span>
</div>
<div class="kopfzeile-rechts">
<span id="kopf-openrouter-status" class="gedaempft text-klein"></span>
<span id="verbindung-punkt" class="status-punkt" title="Ereignisstrom: getrennt"></span>
<button id="theme-schalter" class="schaltflaeche-icon flach" title="Farbschema umschalten" type="button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</button>
<button id="abmelden-btn" class="schaltflaeche flach klein" title="Abmelden" type="button">
Abmelden
</button>
</div>
</header>
<main class="inhalt-scroll">
<div id="ansicht-inhalt"></div>
</main>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
-115
View File
@@ -1,115 +0,0 @@
// ClawdDotNet — API-Zugriffsschicht
//
// Verwaltet das Zugangstoken im sessionStorage und kapselt fetch-Aufrufe.
export const TOKEN_SCHLUESSEL = "clawd.token";
let token = sessionStorage.getItem(TOKEN_SCHLUESSEL) ?? "";
const unberechtigtCallbacks = new Set();
/**
* Gibt das aktuell gespeicherte Token zurück.
* @returns {string}
*/
export function holeToken() {
return token;
}
/**
* Speichert ein neues Token im Speicher und sessionStorage.
* @param {string} neuesToken
*/
export function setzeToken(neuesToken) {
token = (neuesToken || "").trim();
if (token) {
sessionStorage.setItem(TOKEN_SCHLUESSEL, token);
} else {
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
}
}
/**
* Löscht das Token (Abmeldung).
*/
export function loescheToken() {
token = "";
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
}
/**
* Prüft, ob ein Token im Speicher liegt.
* @returns {boolean}
*/
export function hatToken() {
return Boolean(token && token.length > 0);
}
/**
* Registriert einen Rückruf für den Fall einer 401-Ablehnung (sofortiger Login-Sprung).
* @param {() => void} callback
*/
export function beiUnberechtigt(callback) {
unberechtigtCallbacks.add(callback);
return () => unberechtigtCallbacks.delete(callback);
}
function meldeUnberechtigt() {
loescheToken();
for (const cb of unberechtigtCallbacks) {
try {
cb();
} catch (err) {
console.error("Fehler im Unberechtigt-Callback:", err);
}
}
}
/**
* Führt einen Aufruf gegen die API aus.
* @param {string} pfad Pfad relativ zu /api, z.B. "/info" oder "/agents"
* @param {RequestInit} [optionen={}]
* @returns {Promise<any>}
*/
export async function api(pfad, optionen = {}) {
const kopfzeilen = {
...(optionen.headers ?? {}),
Authorization: `Bearer ${token}`
};
if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) {
kopfzeilen["Content-Type"] = "application/json";
optionen.body = JSON.stringify(optionen.body);
}
const antwort = await fetch(`/api${pfad}`, {
...optionen,
headers: kopfzeilen
});
if (antwort.status === 401) {
meldeUnberechtigt();
const fehler = new Error("Kein gültiges Token. Bitte neu anmelden.");
fehler.abgelehnt = true;
fehler.status = 401;
throw fehler;
}
if (!antwort.ok) {
let meldung = `HTTP ${antwort.status}`;
try {
const rumpf = await antwort.json();
meldung = rumpf?.message ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
} catch {
// Wenn kein JSON geliefert wird, bleibt es beim HTTP-Code
}
const fehler = new Error(meldung);
fehler.status = antwort.status;
throw fehler;
}
if (antwort.status === 204) {
return null;
}
return antwort.json();
}
-280
View File
@@ -1,280 +0,0 @@
// ClawdDotNet — Sichere DOM-Helferfunktionen
//
// Leitfaden-Regel 3.1: Fremdtext ist nie HTML. Kein innerHTML im gesamten Baum.
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* Erzeugt ein HTML-Element sicher ohne innerHTML.
* @param {string} tag
* @param {object} [eigenschaften]
* @param {...(Node|string|null|undefined)} kinder
* @returns {HTMLElement}
*/
export function erstelleElement(tag, eigenschaften = {}, ...kinder) {
const el = document.createElement(tag);
for (const [schluessel, wert] of Object.entries(eigenschaften)) {
if (wert === undefined || wert === null) continue;
if (schluessel === "klasse" || schluessel === "className") {
el.className = wert;
} else if (schluessel === "text") {
el.textContent = wert;
} else if (schluessel === "stil" || schluessel === "style") {
if (typeof wert === "string") {
el.style.cssText = wert;
} else {
Object.assign(el.style, wert);
}
} else if (schluessel.startsWith("on") && typeof wert === "function") {
const eventName = schluessel.slice(2).toLowerCase();
el.addEventListener(eventName, wert);
} else if (schluessel === "dataset" && typeof wert === "object") {
Object.assign(el.dataset, wert);
} else if (typeof wert === "boolean") {
if (wert) el.setAttribute(schluessel, "");
else el.removeAttribute(schluessel);
} else {
el.setAttribute(schluessel, String(wert));
}
}
for (const kind of kinder) {
if (kind === null || kind === undefined) continue;
if (typeof kind === "string" || typeof kind === "number") {
el.append(document.createTextNode(String(kind)));
} else if (kind instanceof Node) {
el.append(kind);
}
}
return el;
}
/**
* Erzeugt ein SVG-Icon sicher über den SVG-Namensraum ohne innerHTML.
* @param {string} pfadDaten SVG path d attribute
* @param {number} [groesse=16]
* @param {string} [klasse=""]
* @returns {SVGSVGElement}
*/
export function erstelleIcon(pfadDaten, groesse = 16, klasse = "") {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", String(groesse));
svg.setAttribute("height", String(groesse));
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.75");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
if (klasse) svg.setAttribute("class", `icon ${klasse}`);
else svg.setAttribute("class", "icon");
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", pfadDaten);
svg.appendChild(path);
return svg;
}
/**
* Häufig genutzte SVG-Pfade (Lucide-kompatibel, 24x24).
*/
export const PFADE = {
chat: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z",
agenten: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75",
aufgaben: "M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
dienste: "M4 14a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6z M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z M12 6v6 M8 8h.01 M16 8h.01",
verbrauch: "M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7",
staging: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4",
protokoll: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6",
sicherung: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8",
einstellungen: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M12 16v-4 M12 8h.01",
plus: "M12 5v14 M5 12h14",
muelleimer: "M3 6h18 M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M10 11v6 M14 11v6",
abspielen: "M5 3l14 9-14 9V3z",
abbrechen: "M18 6L6 18 M6 6l12 12",
senden: "M22 2L11 13 M22 2l-7 20-4-9-9-4 20-7z",
leeren: "M19 7l-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16",
aktualisieren: "M21.5 2v6h-6 M2.5 22v-6h6 M2 11.5a10 10 0 0 1 18.8-4.3L21.5 8 M22 12.5a10 10 0 0 1-18.8 4.2L2.5 16",
sonne: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
mond: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z",
menue: "M3 12h18 M3 6h18 M3 18h18",
schliessen: "M18 6L6 18 M6 6l12 12",
pfeilLinks: "M19 12H5 M12 19l-7-7 7-7",
pfeilRechts: "M5 12h14 M12 5l7 7-7 7",
pfeilUnten: "M6 9l6 6 6-6",
haken: "M20 6L9 17l-5-5",
ausrufezeichen: "M12 8v4 M12 16h.01 M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z",
ausloggen: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9"
};
/**
* Erzeugt eine Statusplakette (Badge).
* @param {string} text
* @param {"erfolg"|"warnung"|"gefahr"|"neutral"|"laufend"} art
* @returns {HTMLElement}
*/
export function erstellePlakette(text, art = "neutral") {
return erstelleElement("span", { klasse: `plakette plakette-${art}`, text });
}
/**
* Erstellt eine Karte mit optionalem Blueprint-Rahmen.
* @param {string} [titel]
* @param {HTMLElement[]} [elemente]
* @param {object} [optionen]
* @returns {HTMLElement}
*/
export function erstelleKarte(titel, elemente = [], optionen = {}) {
const card = erstelleElement("div", {
klasse: `karte ${optionen.klasse ?? ""}`.trim()
});
if (titel) {
const kopf = erstelleElement("div", { klasse: "karte-kopf" });
const h = erstelleElement("h2", { text: titel });
kopf.append(h);
if (optionen.aktionen) kopf.append(optionen.aktionen);
card.append(kopf);
}
for (const el of elemente) {
if (el) card.append(el);
}
return card;
}
/**
* Erzeugt einen standardisierten Modaldialog.
* @param {string} titel
* @param {HTMLElement} inhalt
* @param {HTMLElement[]} [aktionen]
* @returns {{ dialog: HTMLElement, oeffnen: () => void, schliessen: () => void }}
*/
export function erstelleModal(titel, inhalt, aktionen = []) {
const overlay = erstelleElement("div", { klasse: "modal-overlay", hidden: true });
const box = erstelleElement("div", { klasse: "modal-box karte" });
const kopf = erstelleElement("div", { klasse: "modal-kopf" });
const h = erstelleElement("h2", { text: titel });
const schliessenBtn = erstelleElement(
"button",
{ klasse: "schaltflaeche-icon flach", title: "Schließen", type: "button" },
erstelleIcon(PFADE.schliessen, 16)
);
kopf.append(h, schliessenBtn);
const koerper = erstelleElement("div", { klasse: "modal-koerper" }, inhalt);
const fuss = erstelleElement("div", { klasse: "modal-fuss" });
for (const aktion of aktionen) fuss.append(aktion);
box.append(kopf, koerper, fuss);
overlay.append(box);
document.body.append(overlay);
const schliessen = () => {
overlay.hidden = true;
overlay.classList.remove("sichtbar");
document.removeEventListener("keydown", tasteHandler);
};
const oeffnen = () => {
overlay.hidden = false;
overlay.classList.add("sichtbar");
document.addEventListener("keydown", tasteHandler);
const erstesInput = box.querySelector("input, select, textarea, button:not(.schaltflaeche-icon)");
erstesInput?.focus();
};
const tasteHandler = (e) => {
if (e.key === "Escape") schliessen();
};
schliessenBtn.addEventListener("click", schliessen);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) schliessen();
});
return { dialog: overlay, oeffnen, schliessen };
}
/**
* Formatiert Zeitangaben (z.B. "14:32:05").
* @param {string|Date|number|null} wert
* @returns {string}
*/
export function formatiereZeit(wert) {
if (!wert) return "—";
const d = wert instanceof Date ? wert : new Date(wert);
if (isNaN(d.getTime())) return String(wert);
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
/**
* Formatiert Datum und Uhrzeit.
* @param {string|Date|number|null} wert
* @returns {string}
*/
export function formatiereDatumZeit(wert) {
if (!wert) return "—";
const d = wert instanceof Date ? wert : new Date(wert);
if (isNaN(d.getTime())) return String(wert);
return d.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
/**
* Formatiert Tokenzahlen mit Tausendertrennzeichen.
* @param {number|null} zahl
* @returns {string}
*/
export function formatiereTokens(zahl) {
if (zahl === null || zahl === undefined) return "0";
return new Intl.NumberFormat("de-DE").format(zahl);
}
/**
* Formatiert Kosten (USD / EUR).
* @param {number|null} kosten
* @returns {string}
*/
export function formatiereKosten(kosten) {
if (kosten === null || kosten === undefined) return "$0.00";
return "$" + Number(kosten).toFixed(4);
}
/**
* Zeigt ein kurzes Benachrichtigungsbanner oben im Fenster.
* @param {string} text
* @param {"info"|"erfolg"|"warnung"|"gefahr"} [art="info"]
* @param {number} [dauerMs=4000]
*/
export function zeigeMeldung(text, art = "info", dauerMs = 4000) {
let container = document.getElementById("toast-container");
if (!container) {
container = erstelleElement("div", { id: "toast-container", klasse: "toast-container" });
document.body.append(container);
}
const toast = erstelleElement("div", { klasse: `toast toast-${art}` });
const iconArt = art === "erfolg" ? PFADE.haken : art === "warnung" || art === "gefahr" ? PFADE.ausrufezeichen : PFADE.info;
toast.append(erstelleIcon(iconArt, 16), erstelleElement("span", { text }));
container.append(toast);
setTimeout(() => {
toast.classList.add("ausblenden");
setTimeout(() => toast.remove(), 300);
}, dauerMs);
}
@@ -1,119 +0,0 @@
// ClawdDotNet — Ereignisstrom (Server-Sent Events)
//
// Hört auf /events und verteilt anwendungsintern Ereignisse (chat, step, run, instance, status).
import { holeToken } from "./api.js";
let strom = null;
const abonnenten = new Map();
const verbindungsStatusCallbacks = new Set();
let verbunden = false;
/**
* Registriert einen Rückruf für Änderungen des Verbindungsstatus (offen / getrennt).
* @param {(status: boolean) => void} callback
*/
export function beiVerbindungsStatus(callback) {
verbindungsStatusCallbacks.add(callback);
callback(verbunden);
return () => verbindungsStatusCallbacks.delete(callback);
}
function meldeStatus(istOffen) {
if (verbunden === istOffen) return;
verbunden = istOffen;
for (const cb of verbindungsStatusCallbacks) {
try {
cb(verbunden);
} catch (err) {
console.error("Fehler im Verbindungsstatus-Callback:", err);
}
}
}
/**
* Abonniert ein bestimmtes Server-Ereignis.
* @param {string} ereignis Name des Ereignisses ("chat", "step", "run", "instance", "status")
* @param {(daten: any) => void} callback
* @returns {() => void} Abmeldefunktion
*/
export function abonnieren(ereignis, callback) {
if (!abonnenten.has(ereignis)) {
abonnenten.set(ereignis, new Set());
}
abonnenten.get(ereignis).add(callback);
return () => {
const s = abonnenten.get(ereignis);
if (s) {
s.delete(callback);
if (s.size === 0) abonnenten.delete(ereignis);
}
};
}
function verteileEreignis(ereignis, rohdaten) {
const gruppe = abonnenten.get(ereignis);
if (!gruppe || gruppe.size === 0) return;
let daten = rohdaten;
try {
daten = JSON.parse(rohdaten);
} catch {
// Falls Rohdaten kein JSON waren, unverändert übergeben
}
for (const cb of gruppe) {
try {
cb(daten);
} catch (err) {
console.error(`Fehler bei Verarbeitung von ${ereignis}:`, err);
}
}
}
/**
* Startet den Ereignisstrom, falls ein Token vorliegt.
*/
export function verbindeStrom() {
trenneStrom();
const token = holeToken();
if (!token) return;
// EventSource kann keine Header setzen: Token wird über die Query übergeben (siehe TokenGate.cs)
strom = new EventSource(`/events?token=${encodeURIComponent(token)}`);
strom.onopen = () => {
meldeStatus(true);
};
// EventSource verbindet automatisch wieder bei Abbruch.
strom.onerror = () => {
meldeStatus(false);
};
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
for (const art of bekannteEreignisse) {
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
}
}
/**
* Schließt die bestehende Verbindung zum Ereignisstrom.
*/
export function trenneStrom() {
if (strom) {
strom.close();
strom = null;
}
meldeStatus(false);
}
/**
* Gibt an, ob der Ereignisstrom aktuell aktiv verbunden ist.
* @returns {boolean}
*/
export function istVerbunden() {
return verbunden;
}
@@ -1,123 +0,0 @@
// ClawdDotNet — Hash-basierter Router
//
// Leitfaden-Regel 3.2: Navigation über #. Kein history.pushState, keine Serverrouten.
const routen = new Map();
let aktiverPfad = "";
let navigationsListener = new Set();
/**
* Registriert einen Handler für eine Route.
* @param {string} muster z.B. "/chat", "/chat/:agentId", "/agenten"
* @param {(parameter: object) => Promise<void>|void} handler
*/
export function registriereRoute(muster, handler) {
routen.set(muster, handler);
}
/**
* Registriert einen allgemeinen Listener, der bei jedem Routenwechsel aufgerufen wird.
* @param {(pfad: string, parameter: object) => void} listener
*/
export function beiRoutenwechsel(listener) {
navigationsListener.add(listener);
return () => navigationsListener.delete(listener);
}
/**
* Navigiert zu einem bestimmten Hash.
* @param {string} hash z.B. "#/agenten" oder "/agenten"
*/
export function navigiere(hash) {
const bereinigt = hash.startsWith("#") ? hash : `#${hash}`;
if (window.location.hash !== bereinigt) {
window.location.hash = bereinigt;
} else {
fuehreRouteAus();
}
}
function parseHash() {
const hash = window.location.hash || "#/chat";
const pfad = hash.startsWith("#") ? hash.slice(1) : hash;
return pfad.startsWith("/") ? pfad : `/${pfad}`;
}
function findePassendeRoute(pfad) {
const pfadTeile = pfad.split("/").filter(Boolean);
for (const [muster, handler] of routen.entries()) {
const musterTeile = muster.split("/").filter(Boolean);
if (pfadTeile.length !== musterTeile.length) continue;
const params = {};
let passt = true;
for (let i = 0; i < musterTeile.length; i++) {
if (musterTeile[i].startsWith(":")) {
const paramName = musterTeile[i].slice(1);
params[paramName] = decodeURIComponent(pfadTeile[i]);
} else if (musterTeile[i] !== pfadTeile[i]) {
passt = false;
break;
}
}
if (passt) {
return { handler, params, muster };
}
}
// Fallback: Erste Route oder 404
return null;
}
async function fuehreRouteAus() {
const pfad = parseHash();
aktiverPfad = pfad;
aktualisiereAktivenNavEintrag(pfad);
const match = findePassendeRoute(pfad);
if (match) {
for (const listener of navigationsListener) {
listener(pfad, match.params);
}
await match.handler(match.params);
} else if (routen.has("/chat")) {
navigiere("#/chat");
}
}
function aktualisiereAktivenNavEintrag(pfad) {
const basisPfad = "/" + pfad.split("/").filter(Boolean)[0];
const navLinks = document.querySelectorAll(".nav-link[href]");
for (const link of navLinks) {
const href = link.getAttribute("href") || "";
const zielPfad = href.startsWith("#") ? href.slice(1) : href;
const zielBasis = "/" + zielPfad.split("/").filter(Boolean)[0];
if (basisPfad === zielBasis) {
link.classList.add("aktiv");
} else {
link.classList.remove("aktiv");
}
}
}
/**
* Initialisiert den Router und startet die Überwachung.
*/
export function initialisiereRouter() {
window.addEventListener("hashchange", fuehreRouteAus);
fuehreRouteAus();
}
/**
* Gibt den aktuellen Pfad zurück.
* @returns {string}
*/
export function holeAktuellenPfad() {
return aktiverPfad;
}
@@ -1,54 +0,0 @@
// ClawdDotNet — Theme-Verwaltung (Hell / Dunkel)
//
// Farbschema nach Theme.axaml / Mockup. Unterstützt system, hell, dunkel.
const THEME_SCHLUESSEL = "clawd.theme";
let aktuellerModus = localStorage.getItem(THEME_SCHLUESSEL) || "system";
const systemQuery = window.matchMedia("(prefers-color-scheme: dark)");
function wendeThemeAn() {
const istDunkel =
aktuellerModus === "dunkel" ||
(aktuellerModus === "system" && systemQuery.matches);
if (istDunkel) {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.setAttribute("data-theme", "light");
}
}
systemQuery.addEventListener("change", () => {
if (aktuellerModus === "system") {
wendeThemeAn();
}
});
/**
* Initialisiert das Theme beim Start.
*/
export function initialisiereTheme() {
wendeThemeAn();
}
/**
* Schaltet zwischen Hell und Dunkel um.
* @returns {string} Neues aktives Theme
*/
export function wechsleTheme() {
const istJetztDunkel = document.documentElement.getAttribute("data-theme") === "dark";
aktuellerModus = istJetztDunkel ? "hell" : "dunkel";
localStorage.setItem(THEME_SCHLUESSEL, aktuellerModus);
wendeThemeAn();
return aktuellerModus;
}
/**
* Gibt das aktuell gewählte Theme zurück.
* @returns {"system"|"hell"|"dunkel"}
*/
export function holeAktuellesTheme() {
return aktuellerModus;
}
-413
View File
@@ -1,413 +0,0 @@
// ClawdDotNet — Agenten-Verwaltung
//
// Übersicht, Anlegen, Löschen von Agenten, Werkzeug-Schalter und maskierte Einstellungen.
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
zeigeMeldung
} from "../modules/dom.js";
let gewaehlterAgentId = null;
let agenten = [];
let entladeEreignisse = [];
export async function rendereAgenten(parameter = {}) {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "agenten-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Agenten" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerAgentDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Agent" })
)
);
const raster = erstelleElement("div", { klasse: "agenten-raster" });
const listeContainer = erstelleElement("div", { klasse: "agenten-liste-bereich" });
const detailContainer = erstelleElement("div", { klasse: "agenten-detail-bereich" });
raster.append(listeContainer, detailContainer);
layout.append(kopfZeile, raster);
container.append(layout);
await ladeUndRendereListe(parameter.agentId);
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Agents") {
ladeUndRendereListe(gewaehlterAgentId);
}
})
);
}
async function ladeUndRendereListe(zielId = null) {
const listeContainer = document.querySelector(".agenten-liste-bereich");
if (!listeContainer) return;
listeContainer.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Agenten…" })
);
try {
agenten = await api("/agents");
listeContainer.replaceChildren();
if (!agenten || agenten.length === 0) {
listeContainer.append(
erstelleElement("div", {
klasse: "hinweis-zentriert",
text: "Keine Agenten eingerichtet."
})
);
rendereDetails(null);
return;
}
const auswahlId = zielId && agenten.some((a) => a.agentId === zielId)
? zielId
: agenten[0].agentId;
for (const a of agenten) {
const eintrag = erstelleElement("div", {
klasse: `agenten-karte ${a.agentId === auswahlId ? "aktiv" : ""}`,
onclick: () => {
document.querySelectorAll(".agenten-karte").forEach((k) => k.classList.remove("aktiv"));
eintrag.classList.add("aktiv");
rendereDetails(a.agentId);
}
});
const top = erstelleElement("div", { klasse: "agent-karte-top" });
const name = erstelleElement("strong", { text: a.displayName });
const status = a.isRunning
? erstellePlakette("Beschäftigt", "warnung")
: erstellePlakette("Bereit", "neutral");
top.append(name, status);
const meta = erstelleElement("div", { klasse: "agent-karte-meta" });
meta.append(
erstelleElement("span", { klasse: "gedaempft", text: `ID: ${a.agentId}` }),
erstelleElement("span", { klasse: "gedaempft", text: ` · Modell: ${a.model}` })
);
const werkzeugeZaehler = a.tools ? a.tools.filter((t) => t.enabled).length : 0;
const werkzeugeText = erstelleElement("div", {
klasse: "agent-karte-werkzeuge gedaempft",
text: `${werkzeugeZaehler} Werkzeuge aktiv`
});
eintrag.append(top, meta, werkzeugeText);
listeContainer.append(eintrag);
}
rendereDetails(auswahlId);
} catch (err) {
listeContainer.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function rendereDetails(agentId) {
gewaehlterAgentId = agentId;
const detailContainer = document.querySelector(".agenten-detail-bereich");
if (!detailContainer) return;
detailContainer.replaceChildren();
if (!agentId) {
detailContainer.append(
erstelleKarte("Agent-Details", [
erstelleElement("p", { klasse: "gedaempft", text: "Wähle links einen Agenten aus." })
])
);
return;
}
const agent = agenten.find((a) => a.agentId === agentId);
if (!agent) return;
// Kopf-Aktionen (Chatten, Löschen)
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const zumChatBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Mit diesem Agenten chatten",
onclick: () => {
window.location.hash = `#/chat/${encodeURIComponent(agent.agentId)}`;
}
},
erstelleIcon(PFADE.chat, 14),
erstelleElement("span", { text: " Chatten" })
);
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach",
title: "Diesen Agenten löschen",
onclick: () => loescheAgent(agent.agentId, agent.displayName)
},
erstelleIcon(PFADE.muelleimer, 14),
erstelleElement("span", { text: " Löschen" })
);
aktionen.append(zumChatBtn, loeschenBtn);
// Stammdaten-Karte
const stammdatenListe = erstelleElement("dl", { klasse: "datenliste" });
const felder = [
["Name", agent.displayName],
["Kennung (ID)", agent.agentId],
["Modell", agent.model],
["Beschreibung", agent.description || "—"]
];
for (const [bez, wert] of felder) {
stammdatenListe.append(
erstelleElement("dt", { text: bez }),
erstelleElement("dd", { text: wert })
);
}
const basisKarte = erstelleKarte(agent.displayName, [stammdatenListe], { aktionen });
// Werkzeuge-Karte
const werkzeugeListe = erstelleElement("div", { klasse: "werkzeuge-liste" });
if (!agent.tools || agent.tools.length === 0) {
werkzeugeListe.append(
erstelleElement("p", { klasse: "gedaempft", text: "Keine Werkzeuge für diesen Agenten konfiguriert." })
);
} else {
for (const tool of agent.tools) {
const zeile = erstelleElement("div", { klasse: "werkzeug-zeile" });
const links = erstelleElement("div", { klasse: "werkzeug-info" });
const toolTitel = erstelleElement("strong", { text: tool.toolName });
const toolDesc = erstelleElement("div", {
klasse: "werkzeug-beschreibung gedaempft",
text: tool.description || ""
});
links.append(toolTitel, toolDesc);
const rechts = erstelleElement("div", { klasse: "werkzeug-aktionen" });
const einstellungenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
title: "Werkzeug-Einstellungen einsehen",
onclick: () => zeigeWerkzeugEinstellungen(agent.agentId, tool.toolName)
},
erstelleIcon(PFADE.einstellungen, 14),
erstelleElement("span", { text: " Konfiguration" })
);
const toggleLabel = erstelleElement("label", { klasse: "schalter" });
const toggleInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(tool.enabled),
onchange: async (e) => {
const aktiv = e.target.checked;
try {
await api(
`/agents/${encodeURIComponent(agent.agentId)}/tools/${encodeURIComponent(tool.toolName)}?enabled=${aktiv}`,
{ method: "PUT" }
);
tool.enabled = aktiv;
zeigeMeldung(`Werkzeug '${tool.toolName}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
} catch (err) {
e.target.checked = !aktiv;
zeigeMeldung(`Änderung fehlgeschlagen: ${err.message}`, "gefahr");
}
}
});
const toggleSpan = erstelleElement("span", { klasse: "schalter-regler" });
toggleLabel.append(toggleInput, toggleSpan);
rechts.append(einstellungenBtn, toggleLabel);
zeile.append(links, rechts);
werkzeugeListe.append(zeile);
}
}
const werkzeugeKarte = erstelleKarte("Verfügbare Werkzeuge", [werkzeugeListe]);
detailContainer.append(basisKarte, werkzeugeKarte);
}
function oeffneNeuerAgentDialog() {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const idFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. scout",
id: "neu-agent-id"
});
const nameFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. Scout Agent",
id: "neu-agent-name"
});
const modellFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. anthropic/claude-3.5-sonnet",
value: "anthropic/claude-3.5-sonnet",
id: "neu-agent-modell"
});
const descFeld = erstelleElement("textarea", {
rows: "3",
placeholder: "Optionale Beschreibung des Agenten…",
id: "neu-agent-desc"
});
formular.append(
erstelleElement("label", { text: "Agent-ID (eindeutiger Schlüssel):" }),
idFeld,
erstelleElement("label", { text: "Anzeigename:" }),
nameFeld,
erstelleElement("label", { text: "Modell (OpenRouter):" }),
modellFeld,
erstelleElement("label", { text: "Beschreibung:" }),
descFeld
);
const abbrechenBtn = erstelleElement(
"button",
{
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen()
},
erstelleElement("span", { text: "Abbrechen" })
);
const speichernBtn = erstelleElement(
"button",
{
type: "submit",
klasse: "schaltflaeche primaer"
},
erstelleElement("span", { text: "Agent anlegen" })
);
const modal = erstelleModal("Neuen Agenten anlegen", formular, [abbrechenBtn, speichernBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
speichernBtn.disabled = true;
try {
await api("/agents", {
method: "POST",
body: {
agentId: idFeld.value.trim(),
displayName: nameFeld.value.trim(),
model: modellFeld.value.trim(),
description: descFeld.value.trim()
}
});
modal.schliessen();
zeigeMeldung(`Agent '${nameFeld.value.trim()}' wurde erfolgreich angelegt.`, "erfolg");
await ladeUndRendereListe(idFeld.value.trim());
} catch (err) {
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
} finally {
speichernBtn.disabled = false;
}
});
modal.oeffnen();
}
async function loescheAgent(agentId, name) {
if (!confirm(`Soll der Agent '${name}' (${agentId}) wirklich gelöscht werden?`)) return;
try {
await api(`/agents/${encodeURIComponent(agentId)}`, { method: "DELETE" });
zeigeMeldung(`Agent '${name}' wurde gelöscht.`, "info");
await ladeUndRendereListe();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function zeigeWerkzeugEinstellungen(agentId, toolName) {
const box = erstelleElement("div", { klasse: "einstellungen-box" });
box.append(erstelleElement("p", { klasse: "gedaempft", text: "Lade Konfiguration…" }));
const schliessenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Schließen"
});
const modal = erstelleModal(
`Einstellungen: ${toolName} (${agentId})`,
box,
[schliessenBtn]
);
modal.oeffnen();
try {
const daten = await api(
`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/settings`
);
box.replaceChildren();
const hinweis = erstelleElement("p", {
klasse: "hinweis",
text: "Geheime Werte (Passwörter, Schlüssel) werden vom Server maskiert ausgegeben."
});
box.append(hinweis);
if (!daten || Object.keys(daten).length === 0) {
box.append(
erstelleElement("p", { klasse: "gedaempft", text: "Keine individuellen Einstellungen hinterlegt." })
);
return;
}
const dl = erstelleElement("dl", { klasse: "datenliste" });
for (const [schluessel, wert] of Object.entries(daten)) {
const dt = erstelleElement("dt", { text: schluessel });
const dd = erstelleElement("dd", { text: typeof wert === "object" ? JSON.stringify(wert) : String(wert) });
dl.append(dt, dd);
}
box.append(dl);
} catch (err) {
box.replaceChildren(
erstelleElement("p", { klasse: "fehler", text: `Laden fehlgeschlagen: ${err.message}` })
);
}
}
-394
View File
@@ -1,394 +0,0 @@
// ClawdDotNet — Chat-Ansicht
//
// Leitfaden: Sicherer Chatverlauf (strikt textContent), Echtzeit-Ereignisse über SSE.
import { api } 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 = [];
export async function rendereChat(parameter = {}) {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
// Alte Ereignisse aufräumen
entladeEreignisse.forEach((cleanup) => cleanup());
entladeEreignisse = [];
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);
// SSE-Abonnements
entladeEreignisse.push(
abonnieren("chat", (daten) => {
if (daten.agentId === aktiverAgentId) {
fuegeNachrichtEin(daten);
}
})
);
entladeEreignisse.push(
abonnieren("step", (daten) => {
if (daten.agentId === aktiverAgentId) {
zeigeSchritt(daten.text);
}
})
);
entladeEreignisse.push(
abonnieren("run", (daten) => {
versteckeSchritt();
aktualisiereLaufStatus(false);
})
);
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Agents") {
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);
window.location.hash = `#/chat/${encodeURIComponent(agentId)}`;
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.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");
}
}
-90
View File
@@ -1,90 +0,0 @@
// ClawdDotNet — System-Info & Status
//
// Zeigt Anwendungsversion, Instanzkennung, Teilsysteme und OpenRouter-Guthaben an.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
erstellePlakette
} from "../modules/dom.js";
export async function rendereInfo() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "info-ansicht" });
layout.append(erstelleElement("h1", { text: "System & Status" }));
const gitter = erstelleElement("div", { klasse: "info-gitter" });
layout.append(gitter);
container.append(layout);
gitter.append(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Systemdaten…" })
);
try {
const [info, status] = await Promise.all([
api("/info").catch(() => null),
api("/status").catch(() => null)
]);
gitter.replaceChildren();
// 1. Instanz & Version
if (info) {
const dlInstanz = erstelleElement("dl", { klasse: "datenliste" });
dlInstanz.append(
erstelleElement("dt", { text: "Instanzname" }),
erstelleElement("dd", {}, erstelleElement("strong", { text: info.instanceName })),
erstelleElement("dt", { text: "Instanz-ID" }),
erstelleElement("dd", {}, erstelleElement("code", { text: info.instanceId })),
erstelleElement("dt", { text: "Version" }),
erstelleElement("dd", { text: info.appVersion }),
erstelleElement("dt", { text: "Build" }),
erstelleElement("dd", { text: info.buildSummary })
);
gitter.append(erstelleKarte("Instanz & Programm", [dlInstanz]));
// 2. Teilsysteme
const dlSysteme = erstelleElement("dl", { klasse: "datenliste" });
dlSysteme.append(
erstelleElement("dt", { text: "Agent Engine" }),
erstelleElement("dd", {}, erstellePlakette(info.engineActive ? "Aktiv" : "Aus", info.engineActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Scanner / Scheduler" }),
erstelleElement("dd", {}, erstellePlakette(info.schedulerActive ? "Aktiv" : "Aus", info.schedulerActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Freigabestelle (Staging)" }),
erstelleElement("dd", {}, erstellePlakette(info.stagingActive ? "Aktiv" : "Aus", info.stagingActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Deploymentcenter" }),
erstelleElement("dd", {}, erstellePlakette(info.deploymentcenterActive ? "Verbunden" : "Nicht konfiguriert", info.deploymentcenterActive ? "erfolg" : "neutral"))
);
gitter.append(erstelleKarte("Dienste & Teilsysteme", [dlSysteme]));
}
// 3. OpenRouter / API-Status
const dlStatus = erstelleElement("dl", { klasse: "datenliste" });
if (status) {
dlStatus.append(
erstelleElement("dt", { text: "API-Erreichbarkeit" }),
erstelleElement("dd", {}, erstellePlakette(status.isApiReachable ? "Verbunden" : "Getrennt", status.isApiReachable ? "erfolg" : "gefahr")),
erstelleElement("dt", { text: "Statusmeldung" }),
erstelleElement("dd", { text: status.statusText || "—" }),
erstelleElement("dt", { text: "Guthaben / Credits" }),
erstelleElement("dd", {}, erstelleElement("strong", { text: status.creditsText || "—" }))
);
} else {
dlStatus.append(
erstelleElement("dt", { text: "OpenRouter" }),
erstelleElement("dd", { klasse: "gedaempft", text: "Kein API-Schlüssel oder Dienst nicht erreichbar." })
);
}
gitter.append(erstelleKarte("Modellanbindung (OpenRouter)", [dlStatus]));
} catch (err) {
gitter.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
@@ -1,71 +0,0 @@
// ClawdDotNet — Anmeldeseite
//
// Leitfaden: Ohne Token soll das UI sofort auf die Login- bzw. Tokeneingabe springen.
import { setzeToken, loescheToken, api } from "../modules/api.js";
import { erstelleElement, PFADE, erstelleIcon } from "../modules/dom.js";
let loginCallback = null;
export function initialisiereLogin(onErfolg) {
loginCallback = onErfolg;
const formular = document.getElementById("anmeldeformular");
const fehlerBox = document.getElementById("anmeldefehler");
if (!formular) return;
formular.addEventListener("submit", async (e) => {
e.preventDefault();
const tokenInput = document.getElementById("token-eingabe");
const token = tokenInput?.value.trim() || "";
if (!token) return;
if (fehlerBox) {
fehlerBox.hidden = true;
fehlerBox.textContent = "";
}
setzeToken(token);
try {
// Teste das Token gegen /api/info
const info = await api("/info");
versteckeAnmeldung();
if (loginCallback) loginCallback(info);
} catch (err) {
loescheToken();
if (fehlerBox) {
fehlerBox.textContent = err.abgelehnt
? "Token ungültig oder kürzer als 20 Zeichen."
: `Anmeldefehler: ${err.message}`;
fehlerBox.hidden = false;
}
}
});
}
export function zeigeAnmeldung() {
const anmeldung = document.getElementById("anmeldung");
const anwendung = document.getElementById("anwendung");
const fehlerBox = document.getElementById("anmeldefehler");
const tokenInput = document.getElementById("token-eingabe");
if (anmeldung) anmeldung.hidden = false;
if (anwendung) anwendung.hidden = true;
if (fehlerBox) {
fehlerBox.hidden = true;
fehlerBox.textContent = "";
}
if (tokenInput) {
tokenInput.value = "";
tokenInput.focus();
}
}
export function versteckeAnmeldung() {
const anmeldung = document.getElementById("anmeldung");
const anwendung = document.getElementById("anwendung");
if (anmeldung) anmeldung.hidden = true;
if (anwendung) anwendung.hidden = false;
}
-224
View File
@@ -1,224 +0,0 @@
// ClawdDotNet — Live-Protokollansicht (Logs)
//
// Fortlaufender Stream über /events/logs, Modul- und Level-Filterung, Autoscroll, strikt textContent.
import { api, holeToken } from "../modules/api.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstellePlakette
} from "../modules/dom.js";
let logStream = null;
let modulFilter = "";
let levelFilter = "All";
let autoScroll = true;
let logContainer = null;
let streamStatusPlakette = null;
let pauseButton = null;
let istPausiert = false;
export async function rendereProtokoll() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
trenneLogStream();
const layout = erstelleElement("div", { klasse: "protokoll-ansicht" });
// Kopfleiste mit Filtern und Steuerungen
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
titelGruppe.append(erstelleElement("h1", { text: "Protokoll" }));
streamStatusPlakette = erstellePlakette("Verbinde…", "neutral");
titelGruppe.append(streamStatusPlakette);
const steuerungGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
// Modul-Auswahl
const modulSelect = erstelleElement("select", {
klasse: "eingabe-select",
onchange: (e) => {
modulFilter = e.target.value;
starteLogStream();
}
});
modulSelect.append(erstelleElement("option", { value: "", text: "Alle Module" }));
// Level-Auswahl
const levelSelect = erstelleElement("select", {
klasse: "eingabe-select",
onchange: (e) => {
levelFilter = e.target.value;
starteLogStream();
}
});
const levelOptionen = [
["All", "Alle Stufen"],
["Debug", "Debug"],
["Information", "Info"],
["Warning", "Warnung"],
["Error", "Fehler"],
["Critical", "Kritisch"]
];
for (const [val, label] of levelOptionen) {
levelSelect.append(erstelleElement("option", { value: val, text: label }));
}
// Autoscroll-Checkbox
const autoScrollLabel = erstelleElement("label", { klasse: "checkbox-label gedaempft" });
const autoScrollInput = erstelleElement("input", {
type: "checkbox",
checked: true,
onchange: (e) => {
autoScroll = e.target.checked;
}
});
autoScrollLabel.append(autoScrollInput, erstelleElement("span", { text: " Auto-Scroll" }));
// Pause / Fortsetzen
pauseButton = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
onclick: togglePause
},
erstelleIcon(PFADE.abbrechen, 14),
erstelleElement("span", { text: " Anhalten" })
);
// Leeren
const leerenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Anzeige leeren",
onclick: () => logContainer?.replaceChildren()
},
erstelleIcon(PFADE.leeren, 14),
erstelleElement("span", { text: " Leeren" })
);
steuerungGruppe.append(modulSelect, levelSelect, autoScrollLabel, pauseButton, leerenBtn);
kopfZeile.append(titelGruppe, steuerungGruppe);
logContainer = erstelleElement("div", { klasse: "protokoll-strom code-block" });
layout.append(kopfZeile, logContainer);
container.append(layout);
// Lade verfügbare Module
try {
const module = await api("/logs/modules");
if (Array.isArray(module)) {
for (const m of module) {
modulSelect.append(erstelleElement("option", { value: m, text: m }));
}
}
} catch {
// Nicht kritisch, Filter bleibt auf "Alle Module"
}
starteLogStream();
}
function starteLogStream() {
trenneLogStream();
if (istPausiert) return;
const token = holeToken();
if (!token) return;
let url = `/events/logs?token=${encodeURIComponent(token)}`;
if (modulFilter) url += `&module=${encodeURIComponent(modulFilter)}`;
if (levelFilter) url += `&level=${encodeURIComponent(levelFilter)}`;
logStream = new EventSource(url);
logStream.onopen = () => {
aktualisiereStatus(true);
};
logStream.onerror = () => {
aktualisiereStatus(false);
};
logStream.addEventListener("log", (e) => {
if (istPausiert) return;
try {
const zeile = JSON.parse(e.data);
fuegeLogZeileEin(zeile);
} catch {
fuegeLogZeileEin({ text: e.data });
}
});
}
function fuegeLogZeileEin(eintrag) {
if (!logContainer) return;
const zeile = erstelleElement("div", { klasse: "log-zeile" });
if (eintrag.level) {
const lvl = String(eintrag.level).toLowerCase();
const badgeArt =
lvl.includes("err") || lvl.includes("crit")
? "gefahr"
: lvl.includes("warn")
? "warnung"
: lvl.includes("info")
? "erfolg"
: "neutral";
zeile.append(erstelleElement("span", { klasse: `log-tag tag-${badgeArt}`, text: eintrag.level }));
}
if (eintrag.module) {
zeile.append(erstelleElement("span", { klasse: "log-modul", text: `[${eintrag.module}] ` }));
}
// Wichtig: textContent, kein innerHTML!
const textSpan = erstelleElement("span", { klasse: "log-text", text: eintrag.text || "" });
zeile.append(textSpan);
logContainer.append(zeile);
// Begrenze auf 300 Zeilen im DOM
while (logContainer.children.length > 300) {
logContainer.firstElementChild?.remove();
}
if (autoScroll) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
function togglePause() {
istPausiert = !istPausiert;
if (istPausiert) {
trenneLogStream();
pauseButton.replaceChildren(erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " Fortsetzen" }));
aktualisiereStatus(false, "Pausiert");
} else {
pauseButton.replaceChildren(erstelleIcon(PFADE.abbrechen, 14), erstelleElement("span", { text: " Anhalten" }));
starteLogStream();
}
}
function aktualisiereStatus(offen, text = null) {
if (!streamStatusPlakette) return;
const t = text || (offen ? "Live" : "Getrennt");
const art = offen ? "erfolg" : "neutral";
const neuePlakette = erstellePlakette(t, art);
streamStatusPlakette.replaceWith(neuePlakette);
streamStatusPlakette = neuePlakette;
}
export function trenneLogStream() {
if (logStream) {
logStream.close();
logStream = null;
}
}
@@ -1,74 +0,0 @@
// ClawdDotNet — Platzhalter-Ansichten für noch nicht freigegebene Kern-Endpunkte
//
// Leitfaden Abschnitt 7: Sicherung, Einstellungen und Agenten-Chats (Analyse).
import { erstelleElement, erstelleKarte, erstelleIcon, PFADE } from "../modules/dom.js";
export function rendereSicherung() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" }));
const karte = erstelleKarte("In Vorbereitung", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die API-Endpunkte für die automatisierte Sicherung und Rücksicherung über das WebUI befinden sich in Arbeit."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Bitte verwende für die Sicherung von Instanzen aktuell die Desktop-Oberfläche von ClawdDotNet."
})
]);
layout.append(karte);
container.append(layout);
}
export function rendereEinstellungen() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Instanz-Einstellungen" }));
const karte = erstelleKarte("In Vorbereitung", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die Konfiguration der Instanz (Port, Token, Logging, Budget) erfolgt über die Datei 'InstanceSettings.json' bzw. über den Einstellungsdialog im Desktop-Fenster."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Ein dedizierter Web-Endpunkt zum Schreiben sensibler Zugangsdaten ist für einen späteren Meilenstein vorgesehen."
})
]);
layout.append(karte);
container.append(layout);
}
export function rendereAgentenChats() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Agenten-Kommunikation" }));
const karte = erstelleKarte("Analyse: Interne Agenten-Chats", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die Protokollierung und Auswertung der direkten Kommunikation zwischen zwei Agenten (A2A) wird mit Vorhaben #208 bereitgestellt."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Direkte Chats mit Agenten können im Bereich 'Chat' geführt werden."
})
]);
layout.append(karte);
container.append(layout);
}
@@ -1,243 +0,0 @@
// ClawdDotNet — Hintergrunddienste
//
// Übersicht und Verwaltung der Instanz-Dienste (Port, Protokoll, Status, Ein-/Ausschalten).
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstelleModal,
zeigeMeldung
} from "../modules/dom.js";
let services = [];
let entladeEreignisse = [];
export async function rendereDienste() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "dienste-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Dienste" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerDienstDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Dienst" })
)
);
const tabelleBereich = erstelleElement("div", { id: "dienste-liste-bereich" });
layout.append(kopfZeile, tabelleBereich);
container.append(layout);
await ladeDienste();
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Services") {
ladeDienste();
}
})
);
}
async function ladeDienste() {
const container = document.getElementById("dienste-liste-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Dienste…" })
);
try {
services = await api("/services");
container.replaceChildren();
if (!services || services.length === 0) {
container.append(
erstelleKarte("Keine Dienste", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Für diese Instanz sind noch keine Hintergrunddienste eingerichtet."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
thead.append(
erstelleElement("tr", {},
erstelleElement("th", { text: "Aktiv" }),
erstelleElement("th", { text: "Dienstname" }),
erstelleElement("th", { text: "Typ" }),
erstelleElement("th", { text: "Port" }),
erstelleElement("th", { text: "Beschreibung" }),
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
)
);
const tbody = erstelleElement("tbody");
for (const s of services) {
const tr = erstelleElement("tr");
// Schalter
const tdStatus = erstelleElement("td");
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
const schalterInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(s.enabled),
onchange: async (e) => {
const aktiv = e.target.checked;
try {
await api(`/services/${encodeURIComponent(s.id)}/enabled?enabled=${aktiv}`, {
method: "PUT"
});
s.enabled = aktiv;
zeigeMeldung(`Dienst '${s.name}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
} catch (err) {
e.target.checked = !aktiv;
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
});
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
tdStatus.append(schalterLabel);
const tdName = erstelleElement("td", {}, erstelleElement("strong", { text: s.name }));
const tdTyp = erstelleElement("td", { text: s.type || "—" });
const tdPort = erstelleElement("td", {}, s.port ? erstelleElement("code", { text: String(s.port) }) : erstelleElement("span", { text: "—" }));
const tdDesc = erstelleElement("td", { klasse: "gedaempft", text: s.description || "—" });
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
title: "Dienst löschen",
onclick: () => loescheDienst(s)
},
erstelleIcon(PFADE.muelleimer, 14)
);
tdAktionen.append(loeschenBtn);
tr.append(tdStatus, tdName, tdTyp, tdPort, tdDesc, tdAktionen);
tbody.append(tr);
}
tabelle.append(thead, tbody);
container.append(tabelle);
} catch (err) {
container.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function loescheDienst(s) {
if (!confirm(`Soll der Dienst '${s.name}' (${s.id}) gelöscht werden?`)) return;
try {
await api(`/services/${encodeURIComponent(s.id)}`, { method: "DELETE" });
zeigeMeldung(`Dienst '${s.name}' wurde gelöscht.`, "info");
await ladeDienste();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
function oeffneNeuerDienstDialog() {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const nameFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. Webhook-Empfänger"
});
const typFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. HTTP, TCP, Socket"
});
const portFeld = erstelleElement("input", {
type: "number",
min: "1",
max: "65535",
placeholder: "z.B. 8081 (optional)"
});
const descFeld = erstelleElement("textarea", {
rows: "2",
placeholder: "Beschreibung des Dienstes…"
});
formular.append(
erstelleElement("label", { text: "Dienstname:" }),
nameFeld,
erstelleElement("label", { text: "Diensttyp:" }),
typFeld,
erstelleElement("label", { text: "Port (optional):" }),
portFeld,
erstelleElement("label", { text: "Beschreibung:" }),
descFeld
);
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
});
const anlegenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Dienst anlegen"
});
const modal = erstelleModal("Neuen Hintergrunddienst anlegen", formular, [abbrechenBtn, anlegenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
anlegenBtn.disabled = true;
try {
await api("/services", {
method: "POST",
body: {
name: nameFeld.value.trim(),
type: typFeld.value.trim(),
port: portFeld.value ? parseInt(portFeld.value, 10) : 0,
description: descFeld.value.trim()
}
});
modal.schliessen();
zeigeMeldung(`Dienst '${nameFeld.value.trim()}' angelegt.`, "erfolg");
await ladeDienste();
} catch (err) {
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
} finally {
anlegenBtn.disabled = false;
}
});
modal.oeffnen();
}
@@ -1,215 +0,0 @@
// ClawdDotNet — Freigabestelle (Staging)
//
// Überprüfung und Genehmigung / Ablehnung von sensiblen Werkzeug-Aufrufen.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
let wartendeAufrufe = [];
export async function holeWartendeAnzahl() {
try {
const liste = await api("/staging");
return Array.isArray(liste) ? liste.length : 0;
} catch {
return 0;
}
}
export async function rendereStaging() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "staging-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Freigabestelle" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
onclick: ladeFreigaben
},
erstelleIcon(PFADE.aktualisieren, 16),
erstelleElement("span", { text: " Aktualisieren" })
)
);
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
layout.append(kopfZeile, listeBereich);
container.append(layout);
await ladeFreigaben();
}
async function ladeFreigaben() {
const container = document.getElementById("staging-liste-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Freigaben…" })
);
try {
wartendeAufrufe = await api("/staging");
container.replaceChildren();
// Aktualisiere Badge in Sidebar
aktualisiereStagingBadge(wartendeAufrufe.length);
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
container.append(
erstelleKarte("Keine offenen Freigaben", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Aktuell warten keine Werkzeug-Aufrufe auf eine manuelle Bestätigung."
})
])
);
return;
}
for (const anfrage of wartendeAufrufe) {
const kopfAktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const freigebenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer klein",
onclick: () => bestaetigeFreigabe(anfrage.id)
},
erstelleIcon(PFADE.haken, 14),
erstelleElement("span", { text: " Freigeben" })
);
const ablehnenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
onclick: () => oeffneAblehnenDialog(anfrage.id)
},
erstelleIcon(PFADE.abbrechen, 14),
erstelleElement("span", { text: " Ablehnen" })
);
kopfAktionen.append(freigebenBtn, ablehnenBtn);
const datenliste = erstelleElement("dl", { klasse: "datenliste" });
datenliste.append(
erstelleElement("dt", { text: "Aufruf-ID" }),
erstelleElement("dd", { text: String(anfrage.id) }),
erstelleElement("dt", { text: "Agent" }),
erstelleElement("dd", { text: anfrage.agentId || "—" }),
erstelleElement("dt", { text: "Werkzeug" }),
erstelleElement("dd", { text: `${anfrage.toolName} → ${anfrage.methodName}` }),
erstelleElement("dt", { text: "Zeitpunkt" }),
erstelleElement("dd", { text: formatiereDatumZeit(anfrage.requestedAt) })
);
const argBox = erstelleElement("div", { klasse: "staging-argumente" });
argBox.append(erstelleElement("strong", { text: "Eingefrorene Argumente:" }));
// Strikt textContent! Argumente können Fremdtext sein
const pre = erstelleElement("pre", {
klasse: "code-block",
text: JSON.stringify(anfrage.arguments, null, 2)
});
argBox.append(pre);
const karte = erstelleKarte(`${anfrage.toolName}.${anfrage.methodName}`, [datenliste, argBox], {
aktionen: kopfAktionen
});
container.append(karte);
}
} catch (err) {
container.replaceChildren(
erstelleKarte("Freigabestelle nicht aktiv", [
erstelleElement("p", {
klasse: "gedaempft",
text: err.message || "In dieser Instanz ist keine Freigabestelle eingerichtet."
})
])
);
}
}
function aktualisiereStagingBadge(anzahl) {
const badge = document.getElementById("staging-badge");
if (!badge) return;
if (anzahl > 0) {
badge.textContent = String(anzahl);
badge.hidden = false;
} else {
badge.hidden = true;
}
}
async function bestaetigeFreigabe(id) {
try {
const res = await api(`/staging/${id}/approve`, { method: "POST" });
zeigeMeldung(`Aufruf freigegeben: ${res?.message ?? "Erfolgreich ausgeführt"}`, "erfolg");
await ladeFreigaben();
} catch (err) {
zeigeMeldung(`Freigabe fehlgeschlagen: ${err.message}`, "gefahr");
}
}
function oeffneAblehnenDialog(id) {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const grundInput = erstelleElement("input", {
type: "text",
placeholder: "Begründung für die Ablehnung an den Agenten…"
});
formular.append(
erstelleElement("label", { text: "Begründung (optional):" }),
grundInput
);
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
});
const ablehnenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche gefahr",
text: "Aufruf ablehnen"
});
const modal = erstelleModal("Werkzeug-Aufruf ablehnen", formular, [abbrechenBtn, ablehnenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
ablehnenBtn.disabled = true;
try {
await api(`/staging/${id}/reject`, {
method: "POST",
body: { reason: grundInput.value.trim() }
});
modal.schliessen();
zeigeMeldung("Aufruf wurde abgelehnt.", "info");
await ladeFreigaben();
} catch (err) {
zeigeMeldung(`Ablehnen fehlgeschlagen: ${err.message}`, "gefahr");
} finally {
ablehnenBtn.disabled = false;
}
});
modal.oeffnen();
}
-397
View File
@@ -1,397 +0,0 @@
// ClawdDotNet — Aufgaben & Geplante Läufe
//
// Zwei Bereiche: Taskboard (Aufgaben) und Geplante Läufe (Jobs mit Cron & Sofortausführung).
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
let aktiverReiter = "jobs"; // "jobs" oder "taskboard"
let jobsListe = [];
let entladeEreignisse = [];
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" });
// Kopf mit Reiter-Umschaltung und Aktionen
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
const titel = erstelleElement("h1", { text: "Aufgaben & Jobs" });
const reiterLeiste = erstelleElement("div", { klasse: "reiter-leiste" });
const reiterJobs = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "jobs" ? "aktiv" : ""}`,
text: "Geplante Läufe (Jobs)",
onclick: () => wechsleReiter("jobs")
});
const reiterTaskboard = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "taskboard" ? "aktiv" : ""}`,
text: "Taskboard",
onclick: () => wechsleReiter("taskboard")
});
reiterLeiste.append(reiterJobs, reiterTaskboard);
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const neuerJobBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerJobDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Job" })
);
aktionen.append(neuerJobBtn);
kopfZeile.append(titel, reiterLeiste, aktionen);
const inhaltBereich = erstelleElement("div", { id: "aufgaben-bereich-inhalt" });
layout.append(kopfZeile, inhaltBereich);
container.append(layout);
await ladeAktivenBereich();
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Jobs" && aktiverReiter === "jobs") {
ladeAktivenBereich();
}
})
);
}
function wechsleReiter(reiter) {
aktiverReiter = reiter;
document.querySelectorAll(".reiter-schaltflaeche").forEach((r) => r.classList.remove("aktiv"));
if (reiter === "jobs") {
document.querySelector(".reiter-schaltflaeche:first-child")?.classList.add("aktiv");
} else {
document.querySelector(".reiter-schaltflaeche:last-child")?.classList.add("aktiv");
}
ladeAktivenBereich();
}
async function ladeAktivenBereich() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
container.replaceChildren();
if (aktiverReiter === "jobs") {
await rendereJobs(container);
} else {
await rendereTaskboard(container);
}
}
// ─── Jobs / Geplante Läufe ───
async function rendereJobs(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade geplante Läufe…" })
);
try {
jobsListe = await api("/jobs");
container.replaceChildren();
if (!jobsListe || jobsListe.length === 0) {
container.append(
erstelleKarte("Keine geplanten Läufe", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Es sind noch keine regelmäßigen Agenten-Läufe oder Tool-Jobs eingerichtet."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
const trKopf = erstelleElement("tr");
trKopf.append(
erstelleElement("th", { text: "Status" }),
erstelleElement("th", { text: "Kennung / Art" }),
erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Cron-Muster" }),
erstelleElement("th", { text: "Nächster Lauf (UTC)" }),
erstelleElement("th", { text: "Letzter Lauf (Lokal)" }),
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
);
thead.append(trKopf);
const tbody = erstelleElement("tbody");
for (const job of jobsListe) {
const tr = erstelleElement("tr");
// Schalter Ein/Aus
const tdStatus = erstelleElement("td");
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
const schalterInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(job.enabled),
onchange: async (e) => {
const neuAktiv = e.target.checked;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}/enabled?agentId=${encodeURIComponent(job.agentId)}&enabled=${neuAktiv}`,
{ method: "PUT" }
);
job.enabled = neuAktiv;
zeigeMeldung(`Job '${job.jobId}' ${neuAktiv ? "aktiviert" : "pausiert"}.`, "info");
} catch (err) {
e.target.checked = !neuAktiv;
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
});
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
tdStatus.append(schalterLabel);
// Name / Art
const tdName = erstelleElement("td");
tdName.append(
erstelleElement("strong", { text: job.jobId }),
erstelleElement("div", {
klasse: "gedaempft text-klein",
text: job.kind === "toolJob" ? `Werkzeug (${job.toolName || ""})` : "Agent-Aufruf"
})
);
// Agent
const tdAgent = erstelleElement("td", { text: job.agentId });
// Cron
const tdCron = erstelleElement("td");
tdCron.append(erstelleElement("code", { text: job.cron }));
// Zeiten
const tdNext = erstelleElement("td", {
text: job.nextRunUtc ? formatiereDatumZeit(job.nextRunUtc) : "—"
});
const tdLast = erstelleElement("td", {
text: job.lastRunLocal ? formatiereDatumZeit(job.lastRunLocal) : "—"
});
// Aktionen (Sofortlauf, Löschen)
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal", stil: "justify-content: flex-end;" });
const ausfuehrenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
title: "Job jetzt sofort manuell ausführen",
onclick: () => fuehreJobAus(job.jobId)
},
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Start" })
);
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
title: "Job löschen",
onclick: () => loescheJob(job)
},
erstelleIcon(PFADE.muelleimer, 14)
);
aktionenGruppe.append(ausfuehrenBtn, loeschenBtn);
tdAktionen.append(aktionenGruppe);
tr.append(tdStatus, tdName, tdAgent, tdCron, tdNext, tdLast, tdAktionen);
tbody.append(tr);
}
tabelle.append(thead, tbody);
container.append(tabelle);
} catch (err) {
container.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function fuehreJobAus(jobId) {
try {
const res = await api(`/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST" });
zeigeMeldung(`Job '${jobId}' angestoßen: ${res?.message ?? "Erfolgreich"}`, "erfolg");
} catch (err) {
zeigeMeldung(`Ausführung fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function loescheJob(job) {
if (!confirm(`Soll der geplante Job '${job.jobId}' gelöscht werden?`)) return;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}?agentId=${encodeURIComponent(job.agentId)}&kind=${encodeURIComponent(job.kind)}`,
{ method: "DELETE" }
);
zeigeMeldung(`Job '${job.jobId}' wurde gelöscht.`, "info");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function oeffneNeuerJobDialog() {
let agenten = [];
try {
agenten = await api("/agents");
} catch {}
const formular = erstelleElement("form", { klasse: "formular-raster" });
const agentSelect = erstelleElement("select", { required: true });
for (const a of agenten) {
agentSelect.append(erstelleElement("option", { value: a.agentId, text: `${a.displayName} (${a.agentId})` }));
}
const kindSelect = erstelleElement("select");
kindSelect.append(
erstelleElement("option", { value: "agentWakeup", text: "Agent Wakeup (Regelmäßiger Aufruf)" }),
erstelleElement("option", { value: "toolJob", text: "Tool Job (Werkzeug-Ausführung)" })
);
const cronInput = erstelleElement("input", {
type: "text",
required: true,
value: "0 * * * *",
placeholder: "z.B. 0 * * * * (stündlich)"
});
const taskMsgInput = erstelleElement("textarea", {
rows: "2",
placeholder: "Optionale Start-Nachricht / Anweisung an den Agenten…"
});
formular.append(
erstelleElement("label", { text: "Ziel-Agent:" }),
agentSelect,
erstelleElement("label", { text: "Art des Jobs:" }),
kindSelect,
erstelleElement("label", { text: "Cron-Ausdruck:" }),
cronInput,
erstelleElement("label", { text: "Aufgaben-Nachricht:" }),
taskMsgInput
);
const abbrechenBtn = erstelleElement(
"button",
{
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
}
);
const anlegenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Job erstellen"
});
const modal = erstelleModal("Neuen geplanten Job anlegen", formular, [abbrechenBtn, anlegenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
anlegenBtn.disabled = true;
try {
await api("/jobs", {
method: "POST",
body: {
agentId: agentSelect.value,
kind: kindSelect.value,
cron: cronInput.value.trim(),
taskMessage: taskMsgInput.value.trim(),
runOnStart: false
}
});
modal.schliessen();
zeigeMeldung("Geplanter Job wurde erstellt.", "erfolg");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Fehler beim Erstellen: ${err.message}`, "gefahr");
} finally {
anlegenBtn.disabled = false;
}
});
modal.oeffnen();
}
// ─── Taskboard ───
async function rendereTaskboard(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Taskboard…" })
);
try {
const tasks = await api("/tasks?limit=50");
container.replaceChildren();
if (!tasks || tasks.length === 0) {
container.append(
erstelleKarte("Taskboard leer", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Aktuell liegen keine offenen Aufgaben im gemeinsamen Arbeitsbereich."
})
])
);
return;
}
const raster = erstelleElement("div", { klasse: "taskboard-raster" });
for (const task of tasks) {
const karte = erstelleKarte(task.title || `Aufgabe ${task.id}`, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: `ID: ${task.id} · Agent: ${task.agentId || "—"}` }),
erstelleElement("div", {
klasse: "task-status-zeile",
stil: "margin-top: 8px;"
}, erstellePlakette(task.status || "Offen", task.status === "Done" ? "erfolg" : "neutral")),
erstelleElement("div", {
klasse: "gedaempft text-klein",
stil: "margin-top: 8px;",
text: `Erstellt: ${formatiereDatumZeit(task.createdAt)}`
})
]);
raster.append(karte);
}
container.append(raster);
} catch (err) {
container.replaceChildren(
erstelleKarte("Taskboard nicht verfügbar", [
erstelleElement("p", {
klasse: "gedaempft",
text: err.message || "In dieser Instanz ist kein Taskboard aktiv (kein gemeinsamer Arbeitsbereich)."
})
])
);
}
}
-176
View File
@@ -1,176 +0,0 @@
// ClawdDotNet — Token-Verbrauch & Analyse
//
// Diagramme (CSS/DOM-Balken ohne externe Bibliothek), Statistiken und Läufetabelle.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
formatiereDatumZeit,
formatiereTokens,
formatiereKosten
} from "../modules/dom.js";
export async function rendereVerbrauch() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
layout.append(erstelleElement("h1", { text: "Token-Verbrauch & Analyse" }));
const inhaltBereich = erstelleElement("div", { id: "verbrauch-inhalt-bereich" });
layout.append(inhaltBereich);
container.append(layout);
await ladeVerbrauch();
}
async function ladeVerbrauch() {
const container = document.getElementById("verbrauch-inhalt-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Verbrauchsdaten…" })
);
try {
const laeufe = await api("/usage?limit=100");
container.replaceChildren();
if (!laeufe || laeufe.length === 0) {
container.append(
erstelleKarte("Keine Daten", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Noch keine Token-Verbrauchsdaten aufgezeichnet."
})
])
);
return;
}
// Statistiken berechnen
let summeTokens = 0;
let summePrompt = 0;
let summeCompletion = 0;
let summeKosten = 0;
const nachAgent = new Map();
const nachModell = new Map();
for (const l of laeufe) {
summeTokens += l.totalTokens || 0;
summePrompt += l.promptTokens || 0;
summeCompletion += l.completionTokens || 0;
summeKosten += Number(l.cost || 0);
const agentKey = l.agentId || "Unbekannt";
nachAgent.set(agentKey, (nachAgent.get(agentKey) || 0) + (l.totalTokens || 0));
const modellKey = l.model || "Unbekannt";
nachModell.set(modellKey, (nachModell.get(modellKey) || 0) + (l.totalTokens || 0));
}
// 1. Kacheln (Statistiken)
const kachelGitter = erstelleElement("div", { klasse: "kachel-gitter" });
kachelGitter.append(
erstelleStatKachel("Gesamtkosten (100 Läufe)", formatiereKosten(summeKosten)),
erstelleStatKachel("Tokens gesamt", formatiereTokens(summeTokens)),
erstelleStatKachel("Eingabe-Tokens (Prompt)", formatiereTokens(summePrompt)),
erstelleStatKachel("Ausgabe-Tokens (Completion)", formatiereTokens(summeCompletion))
);
// 2. Balkendiagramme (rein CSS/DOM)
const diagrammGitter = erstelleElement("div", { klasse: "diagramm-gitter" });
const agentBalken = erstelleBalkenDiagramm("Tokens nach Agent", nachAgent, summeTokens);
const modellBalken = erstelleBalkenDiagramm("Tokens nach Modell", nachModell, summeTokens);
diagrammGitter.append(agentBalken, modellBalken);
// 3. Läufe-Tabelle
const tabelleKarte = erstelleElement("div", { klasse: "karte" });
tabelleKarte.append(erstelleElement("h2", { text: "Letzte Agenten-Läufe" }));
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
thead.append(
erstelleElement("tr", {},
erstelleElement("th", { text: "Zeitpunkt" }),
erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Modell" }),
erstelleElement("th", { text: "Prompt", stil: "text-align: right;" }),
erstelleElement("th", { text: "Completion", stil: "text-align: right;" }),
erstelleElement("th", { text: "Gesamt", stil: "text-align: right;" }),
erstelleElement("th", { text: "Kosten", stil: "text-align: right;" })
)
);
const tbody = erstelleElement("tbody");
for (const lauf of laeufe) {
const tr = erstelleElement("tr");
tr.append(
erstelleElement("td", { text: formatiereDatumZeit(lauf.timestamp) }),
erstelleElement("td", {}, erstelleElement("strong", { text: lauf.agentId })),
erstelleElement("td", { klasse: "gedaempft text-klein", text: lauf.model }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.promptTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.completionTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.totalTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereKosten(lauf.cost) })
);
tbody.append(tr);
}
tabelle.append(thead, tbody);
tabelleKarte.append(tabelle);
container.append(kachelGitter, diagrammGitter, tabelleKarte);
} catch (err) {
container.replaceChildren(
erstelleKarte("Verbrauchsdaten nicht verfügbar", [
erstelleElement("p", { klasse: "gedaempft", text: err.message })
])
);
}
}
function erstelleStatKachel(titel, wert) {
const kachel = erstelleElement("div", { klasse: "stat-kachel karte" });
kachel.append(
erstelleElement("div", { klasse: "stat-titel gedaempft", text: titel }),
erstelleElement("div", { klasse: "stat-wert", text: wert })
);
return kachel;
}
function erstelleBalkenDiagramm(titel, datenMap, gesamt) {
const karte = erstelleKarte(titel);
const container = erstelleElement("div", { klasse: "balken-liste" });
const sortiert = [...datenMap.entries()].sort((a, b) => b[1] - a[1]);
for (const [name, wert] of sortiert) {
const prozent = gesamt > 0 ? ((wert / gesamt) * 100).toFixed(1) : "0";
const zeile = erstelleElement("div", { klasse: "balken-zeile" });
const meta = erstelleElement("div", { klasse: "balken-meta" });
meta.append(
erstelleElement("span", { klasse: "balken-name", text: name }),
erstelleElement("span", { klasse: "balken-wert gedaempft", text: `${formatiereTokens(wert)} (${prozent}%)` })
);
const spur = erstelleElement("div", { klasse: "balken-spur" });
const fuellung = erstelleElement("div", {
klasse: "balken-fuellung",
stil: `width: ${Math.max(1, Number(prozent))}%;`
});
spur.append(fuellung);
zeile.append(meta, spur);
container.append(zeile);
}
karte.append(container);
return karte;
}