Weboberflaeche hinzugefuegt und Fachlogik nach App herausgeloest (#493, #494)

- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming.

- Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften).

- ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert.

- Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
This commit is contained in:
Richard
2026-09-07 21:56:02 +02:00
parent cffd6f4b77
commit 1e9cb789c8
64 changed files with 10543 additions and 554 deletions
@@ -0,0 +1,231 @@
using System.Text.Json;
using System.Threading.Channels;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Der Ereignisstrom: was im Kern passiert, während niemand danach fragt.
///
/// <para><b>Server-Sent Events und nicht WebSocket.</b> Die Richtung ist einseitig —
/// Befehle gehen über gewöhnliche POSTs —, SSE übersteht Zwischenschichten, bringt das
/// Wiederverbinden mit und braucht auf keiner Seite eine zusätzliche Bibliothek.</para>
///
/// <para><b>Die Ereignisse kommen aus Hintergrundfäden.</b> <c>AgentEngine</c> löst sie
/// dort aus, wo der Lauf gerade ist. Deshalb geht hier nichts direkt in die Antwort,
/// sondern erst in einen Kanal, den die Anfrage abarbeitet: Ein Ereignisbehandler, der in
/// den Antwortstrom schreibt, träfe früher oder später auf einen zweiten, der dasselbe
/// tut, und ein <c>HttpResponse</c> verträgt keine zwei Schreiber.</para>
/// </summary>
internal static class EventEndpoints
{
/// <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 Prozesses füllen, in dem die
/// Agenten laufen. Ein verlorener Zwischenstand ist der bessere Verlust — die Seite
/// lädt den Verlauf beim Verbinden ohnehin neu.</para>
/// </summary>
private const int QueueDepth = 256;
public static void MapEvents(this WebApplication app)
{
var events = app.MapGroup("/events");
events.MapGet("/", StreamCoreEvents);
events.MapGet("/logs", StreamLogs);
}
private static async Task StreamCoreEvents(
HttpContext context, AppHost host, CancellationToken ct)
{
var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true
});
void Publish(string type, object payload)
=> queue.Writer.TryWrite(Format(type, payload));
// Die Behandler stehen als benannte Delegaten da, weil sie im finally wieder
// abgemeldet werden muessen. Ein Lambda direkt am += liesse sich nicht mehr
// entfernen, und nach dreissig Neuladen der Seite liefe jede Nachricht
// dreissigmal durch.
void OnChat(string agentId, string role, string content, string? source)
=> Publish("chat", new { agentId, role, content, source, timestamp = DateTime.UtcNow });
void OnStep(string agentId, string text)
=> Publish("step", new { agentId, text });
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
=> Publish("run", new
{
model,
status = result.Status.ToString(),
steps = result.StepCount,
tokens = result.TokensUsed
});
void OnInstanceChanged(InstanceChange change)
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
void OnStatus()
=> Publish("status", new { });
if (host.Engine is { } engine)
{
engine.OnChatEntryAdded += OnChat;
engine.OnStepCompleted += OnStep;
engine.OnRunCompleted += OnRunCompleted;
}
host.InstanceChanged += OnInstanceChanged;
if (host.Status is { } status)
status.OnStatusUpdated += OnStatus;
try
{
await WriteStream(context, queue.Reader, ct);
}
finally
{
if (host.Engine is { } e)
{
e.OnChatEntryAdded -= OnChat;
e.OnStepCompleted -= OnStep;
e.OnRunCompleted -= OnRunCompleted;
}
host.InstanceChanged -= OnInstanceChanged;
if (host.Status is { } s)
s.OnStatusUpdated -= OnStatus;
queue.Writer.TryComplete();
}
}
/// <summary>
/// Das Protokoll, fortlaufend.
///
/// <para><b>Eine eigene <see cref="LogTail"/> je Verbindung</b>, und das ist keine
/// Sparsamkeitsfrage: <c>ReadNew</c> liefert die Zeilen seit dem letzten Aufruf und
/// merkt sich die Stelle. Eine geteilte Ausprägung hieße, dass der erste Browser die
/// Zeilen bekommt und alle anderen nichts.</para>
/// </summary>
private static async Task StreamLogs(
HttpContext context, AppHost host, CancellationToken ct, string? module = null, string? level = null)
{
var tail = new LogTail(host.LogDirectory);
var filter = Enum.TryParse<LogLevelFilter>(level, ignoreCase: true, out var parsed)
? parsed
: LogLevelFilter.All;
var interval = TimeSpan.FromMilliseconds(
Math.Clamp(host.Settings.AppSettings.LogRefreshIntervalMs, 200, 5000));
PrepareResponse(context);
using var timer = new PeriodicTimer(interval);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
var lines = tail.ReadNew(module, filter);
if (lines.Count == 0)
{
// 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);
}
else
{
foreach (var line in lines)
await context.Response.WriteAsync(
Format("log", new LogLineDto(line.Module, line.Level, line.Text)), ct);
}
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt. Der einzige normale Weg, auf dem dieser Strom endet.
}
}
private static async Task WriteStream(
HttpContext context, ChannelReader<string> reader, CancellationToken ct)
{
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\n\n", ct);
await context.Response.Body.FlushAsync(ct);
var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
try
{
var next = reader.ReadAsync(ct).AsTask();
var beat = heartbeat.WaitForNextTickAsync(ct).AsTask();
while (true)
{
var done = await Task.WhenAny(next, beat);
if (done == next)
{
await context.Response.WriteAsync(await next, ct);
next = reader.ReadAsync(ct).AsTask();
}
else
{
if (!await beat) break;
await context.Response.WriteAsync(": still\n\n", ct);
beat = heartbeat.WaitForNextTickAsync(ct).AsTask();
}
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt.
}
catch (ChannelClosedException)
{
// Der Strom wird abgeraeumt.
}
finally
{
heartbeat.Dispose();
}
}
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";
}
private static string Format(string type, object payload)
=> $"event: {type}\ndata: {JsonSerializer.Serialize(payload)}\n\n";
}