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
@@ -0,0 +1,81 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Manager.Web;
/// <summary>
/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
///
/// <para><b>Warum der Manager einen eigenen hat und nicht den der Instanz.</b> Die Instanz
/// hört seit Stufe 2 nur noch auf ihrem Socket, und dort ist die Grenze das Dateisystem —
/// ihr Torwächter kennt deshalb eine Ausnahme für lokale Verbindungen. Hier gibt es die
/// nicht: Alles unter <c>/api</c> und <c>/events</c> kommt über TCP herein, und alles davon
/// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt
/// werden kann.</para>
///
/// <para><b>Was ohne Token ausgeliefert wird, ist die leere Hülle</b> — HTML, CSS und
/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert:
/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die
/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte.
/// In den Dateien steht nichts, was jemand nicht wissen dürfte.</para>
///
/// <para><b>Warum der Vergleich in fester Zeit läuft:</b> Ein gewöhnlicher
/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst,
/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der
/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand.</para>
/// </summary>
public sealed class AccessToken(byte[] expected)
{
/// <summary>Kürzer als das hier darf ein Token nicht sein.</summary>
public const int MinimumLength = 20;
public static AccessToken For(string token)
=> new(Encoding.UTF8.GetBytes(token));
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
public static bool NeedsToken(PathString path)
=> path.StartsWithSegments("/api") || path.StartsWithSegments("/events");
/// <summary>
/// Holt das Token aus der Anfrage.
///
/// <para><c>Authorization: Bearer …</c> ist der Weg. Ein Cookie wäre bequemer und
/// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit,
/// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die
/// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier
/// keine eigene Baustelle.</para>
///
/// <para>Die Ausnahme ist der Ereignisstrom: <c>EventSource</c> im Browser kann keine
/// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der
/// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit
/// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in
/// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse Proxy
/// davor und entscheidet dort, was protokolliert wird.</para>
/// </summary>
public static string? Read(HttpRequest request)
{
var header = request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..].Trim();
if (request.Path.StartsWithSegments("/events")
&& request.Query.TryGetValue("token", out var fromQuery))
return fromQuery.ToString();
return null;
}
public bool Matches(string? candidate)
{
if (string.IsNullOrEmpty(candidate)) return false;
// FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass
// dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis,
// sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit
// verglichen.
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(candidate), expected);
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Manager.Web</RootNamespace>
</PropertyGroup>
<!-- Die EINE Weboberflaeche fuer alle Instanzen (#528, E-M5).
Bewusst Microsoft.NET.Sdk und nicht Microsoft.NET.Sdk.Web: Dieses Projekt ist eine
Bibliothek, die in einem fremden Prozess einen Kestrel hochfaehrt. ASP.NET Core
kommt als Framework-Referenz und damit ohne NuGet-Paket.
KEIN Verweis auf ClawdDotNet.Web: Dort haengt ClawdDotNet.App dran und daran
sechzehn Werkzeugprojekte. Der Manager spricht mit den Instanzen ueber ihren
Socket, nicht ueber geteilte Objekte - und genau deshalb leitet er weiter, statt
Fachlogik zu haben. -->
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.Manager\ClawdDotNet.Manager.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClawdDotNet.Manager.Web.Tests" />
</ItemGroup>
<!-- Die Oberflaeche als gewoehnliche Dateien neben der Programmdatei, nicht
eingebettet: Wer am HTML arbeitet, sieht seine Aenderung nach einem Neuladen im
Browser statt nach einem Neubau. -->
<ItemGroup>
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+154
View File
@@ -0,0 +1,154 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager.Web;
/// <summary>Eine Instanz, wie die Weboberfläche sie sieht.</summary>
public sealed record InstanceDto(string Id, string Name, string Zustand, int? Prozess);
/// <summary>Was beim Anlegen mitkommt.</summary>
public sealed record NewInstanceDto(string Name);
/// <summary>Antwort auf etwas, das gelingen oder scheitern kann.</summary>
public sealed record OutcomeDto(bool Ok, string Meldung);
/// <summary>
/// Die HTTP-Fläche des Managers.
///
/// <para><b>Zwei Sorten, und der Unterschied ist die ganze Architektur:</b> Unter
/// <c>/api/verwaltung</c> steht, was den Manager selbst angeht — welche Instanzen es gibt,
/// anlegen, starten, anhalten. Alles andere unter <c>/api</c> wird unverändert an eine
/// Instanz weitergereicht.</para>
///
/// <para>Wer hier einen Endpunkt ergänzt, der etwas über Agenten, Aufgaben oder
/// Freigaben weiß, hat Fachlogik in den Manager gelegt — die gehört in die Instanz, wo
/// die Avalonia-Oberfläche sie auch findet.</para>
/// </summary>
internal static class ManagerEndpoints
{
// ─── Was den Manager selbst angeht ───
public static void MapVerwaltung(this WebApplication app)
{
var verwaltung = app.MapGroup("/api/verwaltung");
verwaltung.MapGet("/instanzen", (InstanceRegistry registry) =>
registry.Instances.Select(Map).ToList());
verwaltung.MapPost("/instanzen", async (
NewInstanceDto neu, ManagerCore manager, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(neu.Name))
return Results.BadRequest(new OutcomeDto(false, "Eine Instanz braucht einen Namen."));
try
{
var pfad = await manager.CreateInstanceAsync(neu.Name.Trim(), ct);
return Results.Ok(new OutcomeDto(
true, $"Instanz '{neu.Name.Trim()}' angelegt: {pfad}"));
}
catch (InvalidOperationException ex)
{
// Gibt es schon. Das ist eine Antwort auf eine Frage und keine Stoerung
// des Servers - deshalb 400 und nicht 500.
return Results.BadRequest(new OutcomeDto(false, ex.Message));
}
});
verwaltung.MapPost("/instanzen/{id}/start", async (
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
var ergebnis = await manager.StartInstanceAsync(instanz.Directory, ct);
return ergebnis switch
{
StartRefusal.None =>
Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' läuft.")),
// 402: Der Grund liegt nicht an der Anfrage, sondern an der Lizenz.
StartRefusal.NoLicense => Results.Json(
new OutcomeDto(false, "Ohne gültige Lizenz startet keine Instanz."),
statusCode: StatusCodes.Status402PaymentRequired),
_ => Results.Json(
new OutcomeDto(false,
$"Instanz '{instanz.Name}' ist nicht hochgekommen. "
+ "Der Grund steht im Protokoll des Managers."),
statusCode: StatusCodes.Status500InternalServerError)
};
});
verwaltung.MapPost("/instanzen/{id}/stop", async (
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
var beendet = await manager.StopInstanceAsync(instanz.Directory, ct);
return beendet
? Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' beendet."))
: Results.Json(
new OutcomeDto(false,
$"Instanz '{instanz.Name}' hört nicht und ist von hier aus nicht zu beenden."),
statusCode: StatusCodes.Status409Conflict);
});
}
// ─── Der Ereignisstrom ───
public static void MapEvents(this WebApplication app)
{
var logger = app.Services
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ClawdDotNet.Manager.Web");
// Zwei Stroeme, dieselbe Zusammenfuehrung: die Kernereignisse und das Protokoll.
// Beide gewinnen dadurch, dass an jeder Zeile steht, aus welcher Instanz sie kommt.
app.MapGet("/events", (HttpContext context, InstanceRegistry registry, CancellationToken ct)
=> MergedEventStream.StreamAsync(context, registry, "/events", logger, ct));
app.MapGet("/events/logs", (HttpContext context, InstanceRegistry registry, CancellationToken ct)
=> MergedEventStream.StreamAsync(
context, registry, "/events/logs" + context.Request.QueryString, logger, ct));
}
// ─── Alles andere: durchreichen ───
public static void MapForwarding(this WebApplication app)
{
app.Map("/api/{**pfad}", async (
HttpContext context, InstanceRegistry registry, InstanceForwarding forwarding,
CancellationToken ct) =>
{
var (instanz, problem) = InstanceRouting.Resolve(registry, context);
if (instanz is null)
{
// 409 und nicht 404: Der Pfad ist in Ordnung, nur ist unklar oder
// unmoeglich, an wen er gehen soll.
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsJsonAsync(new OutcomeDto(false, problem!), ct);
return;
}
await forwarding.ForwardAsync(context, instanz, context.Request.Path, ct);
});
}
// ─── Kleinkram ───
private static InstanceDto Map(ManagedInstance instanz) => new(
InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), instanz.ProcessId);
private static ManagedInstance? Find(InstanceRegistry registry, string id)
=> registry.Instances.FirstOrDefault(
i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase));
}
@@ -0,0 +1,153 @@
using ClawdDotNet.Core.Instances;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager.Web;
/// <summary>
/// Reicht eine Anfrage an den Socket der gemeinten Instanz durch.
///
/// <para><b>Er leitet weiter, er implementiert nicht</b> (E-M5). Diese Klasse kennt
/// Kennungen und Pfade, sonst nichts — keinen Agenten, keine Aufgabe, kein Taskboard. Wer
/// hier anfängt, eine Antwort umzuformen oder eine Regel anzuwenden, hat Fachlogik in den
/// Manager gelegt, und die gehört in die Instanz.</para>
///
/// <para><b>Deshalb bleibt das Protokoll HTTP</b> (E-M1). Weil auf beiden Seiten HTTP
/// steht, ist Weiterleiten eine Weiterleitung und keine Übersetzung: Die Endpunkte, die
/// Übertragungstypen und der Ereignisstrom aus #494 gelten unverändert weiter, es ändert
/// sich nur die Bindung.</para>
/// </summary>
internal sealed class InstanceForwarding(ILogger logger) : IDisposable
{
/// <summary>
/// Je Instanz ein Client, und der bleibt.
///
/// <para>Ein <c>HttpClient</c> je Anfrage wäre hier besonders teuer: Jeder brächte
/// einen eigenen Verbindungsaufbau zum Socket mit, und die Weboberfläche fragt viel
/// und oft.</para>
/// </summary>
private readonly Dictionary<string, HttpClient> _clients =
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
private readonly Lock _lock = new();
/// <summary>
/// Kopfzeilen, die nicht weitergereicht werden.
///
/// <para><c>Authorization</c>, weil der Socket kein Token kennt und keines bekommen
/// soll — die Grenze steht dort am Dateisystem. <c>Host</c>, weil die Gegenstelle
/// keinen Hostnamen hat. Die Übertragungskopfzeilen, weil sie die Verbindung
/// beschreiben und nicht den Inhalt: Sie einfach mitzunehmen ergibt eine Antwort, die
/// doppelt gestückelt ist.</para>
/// </summary>
private static readonly HashSet<string> NotForwarded = new(StringComparer.OrdinalIgnoreCase)
{
"Authorization", "Host", "Connection", "Transfer-Encoding",
"Keep-Alive", "Upgrade", "Proxy-Authorization", "TE", "Trailer",
InstanceRouting.HeaderName
};
public async Task ForwardAsync(
HttpContext context, ManagedInstance ziel, string pfad, CancellationToken ct)
{
var client = ClientFor(ziel.Directory);
using var anfrage = new HttpRequestMessage(
new HttpMethod(context.Request.Method),
pfad + context.Request.QueryString);
if (context.Request.ContentLength is > 0 || context.Request.Headers.ContentLength is null)
{
// Den Rumpf stroemen lassen statt ihn zu puffern: Eine hochgeladene Datei
// muss nicht erst vollstaendig im Manager liegen, bevor die Instanz sie sieht.
anfrage.Content = new StreamContent(context.Request.Body);
foreach (var (name, werte) in context.Request.Headers)
{
if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase))
anfrage.Content.Headers.TryAddWithoutValidation(name, werte.ToArray());
}
}
foreach (var (name, werte) in context.Request.Headers)
{
if (NotForwarded.Contains(name)) continue;
if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase)) continue;
anfrage.Headers.TryAddWithoutValidation(name, werte.ToArray());
}
HttpResponseMessage antwort;
try
{
antwort = await client.SendAsync(
anfrage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Die Instanz antwortet nicht. 502 und nicht 500: Der Fehler liegt nicht hier,
// und das gehoert in die Antwort - sonst sucht jemand im Manager nach einem
// Problem, das in der Instanz steckt oder darin, dass sie gar nicht laeuft.
logger.LogWarning(ex,
"Instanz {Name} war für {Methode} {Pfad} nicht erreichbar.",
ziel.Name, context.Request.Method, pfad);
context.Response.StatusCode = StatusCodes.Status502BadGateway;
await context.Response.WriteAsJsonAsync(
new { fehler = $"Die Instanz '{ziel.Name}' antwortet nicht." }, ct);
return;
}
using (antwort)
{
context.Response.StatusCode = (int)antwort.StatusCode;
foreach (var (name, werte) in antwort.Headers)
{
if (!NotForwarded.Contains(name))
context.Response.Headers[name] = werte.ToArray();
}
foreach (var (name, werte) in antwort.Content.Headers)
{
if (!NotForwarded.Contains(name))
context.Response.Headers[name] = werte.ToArray();
}
// Kestrel bestimmt selbst, wie er stueckelt.
context.Response.Headers.Remove("transfer-encoding");
await antwort.Content.CopyToAsync(context.Response.Body, ct).ConfigureAwait(false);
}
}
private HttpClient ClientFor(string instanceDirectory)
{
lock (_lock)
{
if (_clients.TryGetValue(instanceDirectory, out var vorhanden))
return vorhanden;
var client = InstanceEndpoint.CreateClient(instanceDirectory);
// Grosszuegig: Hinter einer weitergeleiteten Anfrage kann ein Agentenlauf
// stecken, und der dauert, so lange er dauert. Der Ereignisstrom laeuft
// ohnehin nicht hierueber.
client.Timeout = TimeSpan.FromMinutes(10);
_clients[instanceDirectory] = client;
return client;
}
}
public void Dispose()
{
lock (_lock)
{
foreach (var client in _clients.Values) client.Dispose();
_clients.Clear();
}
}
}
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Manager.Web;
/// <summary>
/// Welche Instanz eine Anfrage meint.
///
/// <para><b>Der Ordnername ist die Kennung.</b> Er ist eindeutig — zwei Instanzen können
/// nicht im selben Verzeichnis liegen —, er ist stabil, und er ist lesbar. Die
/// Instanzkennung aus der Konfiguration wäre ebenfalls eindeutig, aber man müsste jede
/// Instanzdatei lesen, um von ihr auf ein Verzeichnis zu kommen; der Manager hat den Pfad
/// ohnehin in der Hand.</para>
///
/// <para><b>Und wenn nichts dabeisteht?</b> Läuft genau eine Instanz, ist sie gemeint —
/// alles andere wäre Förmlichkeit gegenüber dem Normalfall. Laufen mehrere, wird
/// nachgefragt statt geraten: Eine Anfrage an die falsche Instanz ist schlimmer als eine
/// abgelehnte.</para>
/// </summary>
internal static class InstanceRouting
{
/// <summary>Die Kopfzeile, mit der die Weboberfläche die Instanz benennt.</summary>
public const string HeaderName = "X-Instanz";
public static string IdOf(ManagedInstance instance) => Path.GetFileName(instance.Directory);
/// <summary>Die gemeinte Instanz, oder eine Begründung, warum keine bestimmbar ist.</summary>
public static (ManagedInstance? Instance, string? Problem) Resolve(
InstanceRegistry registry, HttpContext context)
{
var alle = registry.Instances;
if (context.Request.Headers.TryGetValue(HeaderName, out var genannt)
&& !string.IsNullOrWhiteSpace(genannt))
{
var id = genannt.ToString().Trim();
var treffer = alle.FirstOrDefault(
i => string.Equals(IdOf(i), id, StringComparison.OrdinalIgnoreCase));
return treffer is null
? (null, $"Es gibt keine Instanz '{id}'.")
: (treffer, null);
}
var laufend = alle.Where(i => i.Condition == InstanceCondition.Running).ToList();
return laufend.Count switch
{
1 => (laufend[0], null),
0 => (null, "Es läuft keine Instanz."),
_ => (null, $"Es laufen {laufend.Count} Instanzen. "
+ $"Welche gemeint ist, gehört in die Kopfzeile {HeaderName}.")
};
}
}
@@ -0,0 +1,228 @@
using ClawdDotNet.Core.Logging;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager.Web;
/// <summary>
/// Die eine Weboberfläche für alle Instanzen (#528, E-M5).
///
/// <para><b>Eine Adresse, ein Zugang, alle Instanzen.</b> Der Manager besitzt Port, Zugang
/// und Rahmen; Anfragen zu Chat, Agenten, Aufgaben und Freigaben reicht er über den Socket
/// an die betroffene Instanz weiter. Vorher hatte jede Instanz ihren eigenen Port, und
/// zwei Instanzen stritten sich stillschweigend um die Vorbelegung 7001.</para>
///
/// <para><b>Er leitet weiter, er implementiert nicht.</b> Unter <c>/api/verwaltung</c>
/// steht, was den Manager selbst angeht: welche Instanzen es gibt, anlegen, starten,
/// anhalten. Alles andere unter <c>/api</c> geht unverändert an eine Instanz. Wer hier
/// einen Endpunkt ergänzt, der etwas über Agenten oder Aufgaben weiß, hat Fachlogik in
/// den Manager gelegt.</para>
///
/// <para><b>Zwei Dinge, die er nicht kann und nicht können soll:</b> Er fasst keine
/// <c>state.db</c> an — die gehört genau einem Prozess, und prozessübergreifend gilt die
/// Schreibsperre von <c>SqliteStorage</c> nicht. Und er führt keine Werkzeuge aus;
/// Telegram, Rocket.Chat, Mail und FTP laufen ausschließlich in den Instanzen.</para>
/// </summary>
public sealed class ManagerWebHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly ILogger _logger;
private readonly InstanceForwarding _forwarding;
private ManagerWebHost(
WebApplication app, ILogger logger, InstanceForwarding forwarding, string url)
{
_app = app;
_logger = logger;
_forwarding = forwarding;
Url = url;
}
/// <summary>Die Adresse, unter der die Oberfläche hört.</summary>
public string Url { get; }
/// <summary>
/// Fährt die Weboberfläche hoch.
///
/// <para><b>Gibt in jedem Fehlerfall <c>null</c> zurück und wirft nicht.</b> Ein
/// Manager, der nicht anläuft, weil ein Port belegt ist, wäre genau dann im Weg, wenn
/// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
/// steht im Protokoll.</para>
/// </summary>
public static ManagerWebHost? TryStart(ManagerCore manager, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
var settings = manager.Settings;
var token = settings.WebUiToken?.Trim() ?? "";
if (token.Length < AccessToken.MinimumLength)
{
// Keine halben Sachen: Ein Netzwerkdienst, der Agenten mit Datei-, Mail- und
// FTP-Zugriff steuert, ist nichts, was mit einem schwachen oder gar keinem
// Token offen stehen darf.
logger.LogWarning(
"Weboberfläche nicht gestartet: Es fehlt ein Zugangstoken von mindestens "
+ "{Mindestlänge} Zeichen (webUiToken in manager.json).",
AccessToken.MinimumLength);
return null;
}
var loopback = !string.Equals(settings.WebUiBinding, "any", StringComparison.OrdinalIgnoreCase);
var host = loopback ? "127.0.0.1" : "0.0.0.0";
var url = $"http://{host}:{settings.WebUiPort}";
var forwarding = new InstanceForwarding(logger);
try
{
var app = Build(manager, loggerFactory, url, token, forwarding);
app.StartAsync().GetAwaiter().GetResult();
var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName);
logger.LogInformation(
"Weboberfläche hört auf {Url} ({Bindung})",
anzeige, loopback ? "nur lokal" : "alle Adressen");
return new ManagerWebHost(app, logger, forwarding, anzeige);
}
catch (Exception ex)
{
forwarding.Dispose();
logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", url);
return null;
}
}
private static WebApplication Build(
ManagerCore manager,
ILoggerFactory loggerFactory,
string url,
string token,
InstanceForwarding forwarding)
{
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
ContentRootPath = AppContext.BaseDirectory,
EnvironmentName = Environments.Production,
ApplicationName = typeof(ManagerWebHost).Assembly.GetName().Name
});
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
// ASP.NET protokolliert auf Information jede einzelne Anfrage - auch jede
// Schriftart und jedes Stylesheet. Ab Warnung ist es das, was man wissen will.
builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning);
builder.WebHost.UseUrls(url);
builder.Services.AddSingleton(manager);
builder.Services.AddSingleton(manager.Instances);
builder.Services.AddSingleton(AccessToken.For(token));
builder.Services.AddSingleton(forwarding);
var app = builder.Build();
app.Use(ReportUnhandled(loggerFactory));
app.Use(CheckToken);
MapStaticFiles(app);
app.MapVerwaltung();
app.MapEvents();
app.MapForwarding();
return app;
}
private static Func<HttpContext, RequestDelegate, Task> ReportUnhandled(
ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
return async (context, next) =>
{
try
{
await next(context);
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt, oder der Manager faehrt herunter. Bewusst
// ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht
// ApplicationStopping ab, und RequestAborted ist dabei nicht gesetzt.
}
catch (Exception ex)
{
logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path);
if (context.Response.HasStarted) return;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { fehler = "Interner Fehler." });
}
};
}
private static async Task CheckToken(HttpContext context, RequestDelegate next)
{
if (!AccessToken.NeedsToken(context.Request.Path))
{
await next(context);
return;
}
var gate = context.RequestServices.GetRequiredService<AccessToken>();
if (!gate.Matches(AccessToken.Read(context.Request)))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." });
return;
}
await next(context);
}
private static void MapStaticFiles(WebApplication app)
{
var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(webRoot))
{
app.Logger.LogWarning(
"Kein wwwroot unter {Pfad} — es läuft nur die API.", webRoot);
return;
}
var files = new PhysicalFileProvider(webRoot);
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
}
public async ValueTask DisposeAsync()
{
try
{
await _app.StopAsync(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden");
}
_forwarding.Dispose();
await _app.DisposeAsync();
}
}
@@ -0,0 +1,217 @@
using System.Text;
using System.Text.Json.Nodes;
using System.Threading.Channels;
using ClawdDotNet.Core.Instances;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager.Web;
/// <summary>
/// N Instanzen, ein Ereignisstrom zum Browser.
///
/// <para><b>Die eine Stelle, die wirklich neu gedacht werden musste</b> (Konzept,
/// Abschnitt 8). Alles andere an der Weboberfläche ließ sich weiterleiten; ein
/// Ereignisstrom nicht. Der Browser hält eine Verbindung, die Instanzen halten je eine,
/// und dazwischen muss jemand zusammenführen — und an jedes Ereignis schreiben, von wem
/// es kommt. Ohne das wäre in einem Fenster mit drei Instanzen nicht zu erkennen, welcher
/// Agent gerade spricht.</para>
///
/// <para><b>Ergänzt statt verpackt.</b> Es wäre einfacher, jedes Ereignis in einen
/// Umschlag zu stecken (<c>{"instanz":…,"daten":…}</c>) — dann müsste aber jede Ansicht
/// der Weboberfläche umgeschrieben werden. Stattdessen bekommt das vorhandene Objekt ein
/// Feld <c>instanz</c> dazu; alles, was heute Felder liest, liest sie weiter.</para>
///
/// <para><b>Eine tote Instanz reißt den Strom nicht ab.</b> Fällt eine weg, endet ihre
/// Zuleitung, und die anderen laufen weiter. Der Browser merkt davon nichts — was er
/// merken soll, sagt ihm die Aufsicht als eigenes Ereignis.</para>
/// </summary>
internal static class MergedEventStream
{
/// <summary>
/// Wie viele Ereignisse ein langsamer Browser hinter sich herziehen darf, bevor die
/// ältesten wegfallen.
///
/// <para>Begrenzt und verwerfend, nicht unbegrenzt: Ein Tab, der im Hintergrund
/// eingefroren ist, würde sonst den Speicher des Managers füllen. Ein verlorener
/// Zwischenstand ist der bessere Verlust — die Seite lädt den Verlauf beim Verbinden
/// ohnehin neu.</para>
/// </summary>
private const int QueueDepth = 512;
public static async Task StreamAsync(
HttpContext context,
InstanceRegistry registry,
string instancePath,
ILogger logger,
CancellationToken ct)
{
var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true
});
var laufend = registry.Instances
.Where(i => i.Condition == InstanceCondition.Running)
.ToList();
using var leben = CancellationTokenSource.CreateLinkedTokenSource(ct);
var zuleitungen = laufend
.Select(instanz => PumpAsync(instanz, instancePath, queue.Writer, logger, leben.Token))
.ToList();
PrepareResponse(context);
// Sofort etwas schicken: Erst damit steht die Verbindung fuer den Browser, und
// onopen feuert. Ohne das haengt die Seite im Ungewissen, bis das erste Ereignis
// von selbst kommt - was Stunden dauern kann.
await context.Response.WriteAsync(
$": bereit ({laufend.Count} Instanzen)\n\n", ct).ConfigureAwait(false);
await context.Response.Body.FlushAsync(ct).ConfigureAwait(false);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
try
{
var naechstes = queue.Reader.ReadAsync(ct).AsTask();
var schlag = heartbeat.WaitForNextTickAsync(ct).AsTask();
while (true)
{
var fertig = await Task.WhenAny(naechstes, schlag).ConfigureAwait(false);
if (fertig == naechstes)
{
await context.Response.WriteAsync(await naechstes, ct).ConfigureAwait(false);
naechstes = queue.Reader.ReadAsync(ct).AsTask();
}
else
{
if (!await schlag) break;
// Ein Kommentar haelt die Verbindung offen, ohne ein Ereignis
// vorzutaeuschen. Ohne ihn kappt so mancher Proxy nach einer Minute.
await context.Response.WriteAsync(": still\n\n", ct).ConfigureAwait(false);
schlag = heartbeat.WaitForNextTickAsync(ct).AsTask();
}
await context.Response.Body.FlushAsync(ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt, oder der Manager faehrt herunter.
}
catch (ChannelClosedException)
{
// Der Strom wird abgeraeumt.
}
finally
{
await leben.CancelAsync().ConfigureAwait(false);
queue.Writer.TryComplete();
try { await Task.WhenAll(zuleitungen).ConfigureAwait(false); }
catch { /* Abgebrochene Zuleitungen sind der Normalfall beim Aufraeumen. */ }
}
}
/// <summary>
/// Eine Zuleitung: hört am Socket einer Instanz und schiebt weiter, was kommt.
/// </summary>
private static async Task PumpAsync(
ManagedInstance instanz,
string instancePath,
ChannelWriter<string> ziel,
ILogger logger,
CancellationToken ct)
{
var id = InstanceRouting.IdOf(instanz);
try
{
using var client = InstanceEndpoint.CreateClient(instanz.Directory);
// Kein Zeitlimit: Ein Ereignisstrom endet nie von selbst - das ist sein Zweck.
client.Timeout = Timeout.InfiniteTimeSpan;
using var antwort = await client.GetAsync(
instancePath, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
antwort.EnsureSuccessStatusCode();
await using var strom = await antwort.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
using var leser = new StreamReader(strom, Encoding.UTF8);
string? art = null;
while (await leser.ReadLineAsync(ct).ConfigureAwait(false) is { } zeile)
{
if (zeile.StartsWith(": ", StringComparison.Ordinal))
continue; // Herzschlag der Instanz. Der Manager hat einen eigenen.
if (zeile.StartsWith("event: ", StringComparison.Ordinal))
{
art = zeile["event: ".Length..];
continue;
}
if (!zeile.StartsWith("data: ", StringComparison.Ordinal))
continue;
ziel.TryWrite(Format(art ?? "message", zeile["data: ".Length..], id, instanz.Name));
art = null;
}
}
catch (OperationCanceledException)
{
// Der Strom wird abgeraeumt oder der Browser hat aufgelegt.
}
catch (Exception ex)
{
// Eine Instanz, die waehrend des Zuhoerens weggeht, ist kein Fehler des
// Managers - und sie darf die anderen Zuleitungen nicht mitnehmen.
logger.LogInformation(
"Ereigniszuleitung von Instanz {Name} beendet: {Grund}", instanz.Name, ex.Message);
}
}
/// <summary>
/// Hängt <c>instanz</c> und <c>instanzName</c> an das Ereignis.
///
/// <para>Ist der Inhalt kein Objekt — oder gar kein JSON —, wird er unverändert
/// weitergereicht. Ein Ereignis zu verschlucken, weil sein Inhalt unerwartet aussieht,
/// wäre die schlechtere Antwort: Der Manager leitet weiter, er urteilt nicht.</para>
/// </summary>
private static string Format(string art, string daten, string id, string name)
{
try
{
if (JsonNode.Parse(daten) is JsonObject objekt)
{
objekt["instanz"] = id;
objekt["instanzName"] = name;
return $"event: {art}\ndata: {objekt.ToJsonString()}\n\n";
}
}
catch (System.Text.Json.JsonException)
{
// Siehe oben.
}
return $"event: {art}\ndata: {daten}\n\n";
}
private static void PrepareResponse(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
// Ohne das puffert ein vorgelagerter nginx den Strom und liefert ihn erst, wenn
// er voll ist - aus einem Ereignisstrom wird dann eine Ereignissammlung.
context.Response.Headers["X-Accel-Buffering"] = "no";
}
}
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
// 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();
}
@@ -0,0 +1,93 @@
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.
@@ -0,0 +1,172 @@
<!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>
@@ -0,0 +1,115 @@
// 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();
}
@@ -0,0 +1,280 @@
// 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);
}
@@ -0,0 +1,119 @@
// 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;
}
@@ -0,0 +1,123 @@
// 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;
}
@@ -0,0 +1,54 @@
// 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;
}
@@ -0,0 +1,413 @@
// 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}` })
);
}
}
@@ -0,0 +1,394 @@
// 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");
}
}
@@ -0,0 +1,90 @@
// 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 })
);
}
}
@@ -0,0 +1,71 @@
// 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;
}
@@ -0,0 +1,224 @@
// 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;
}
}
@@ -0,0 +1,74 @@
// 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);
}
@@ -0,0 +1,243 @@
// 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();
}
@@ -0,0 +1,215 @@
// 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();
}
@@ -0,0 +1,397 @@
// 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)."
})
])
);
}
}
@@ -0,0 +1,176 @@
// 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;
}