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,340 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Die HTTP-Fläche der Weboberfläche.
///
/// <para>Jeder Endpunkt hier ist dünn: Er nimmt entgegen, ruft einen Dienst aus
/// <c>ClawdDotNet.App</c> und formt das Ergebnis um. <b>Eine Regel, die nur hier steht,
/// ist eine an der falschen Stelle</b> — sie gehört in die Anwendungsschicht, wo die
/// Avalonia-Oberfläche sie auch findet. Wer beim Bauen eines Endpunkts merkt, dass er
/// etwas entscheiden muss, hat einen Befund und keine Aufgabe.</para>
/// </summary>
internal static class ApiEndpoints
{
public static void MapApi(this WebApplication app)
{
var api = app.MapGroup("/api");
api.MapInfo();
api.MapAgents();
api.MapChat();
api.MapJobs();
api.MapServices();
api.MapTasks();
api.MapStaging();
api.MapUsage();
api.MapLogs();
app.MapEvents();
}
/// <summary>
/// Übersetzt ein <see cref="AdminResult"/> in eine Antwort.
///
/// <para>400 und nicht 500, wenn es nicht geklappt hat: Eine doppelte Agenten-ID ist
/// keine Störung des Servers, sondern eine Antwort auf eine Frage. Die Begründung
/// steht im Rumpf und ist dieselbe, die im Fenster stünde.</para>
/// </summary>
public static IResult ToResponse(this AdminResult result)
=> result.Ok
? Results.Ok(new OutcomeDto(true, result.Message))
: Results.BadRequest(new OutcomeDto(false, result.Message));
/// <summary>
/// Für Bereiche, die es ohne Zutun nicht gibt: keine Engine ohne API-Schlüssel, kein
/// Scanner ohne gemeinsamen Arbeitsbereich, kein Staging ohne beides.
///
/// <para>503 mit einer Begründung statt einer <c>NullReferenceException</c>. Die
/// Oberfläche kann daraus einen Satz machen; aus einem Absturz nicht.</para>
/// </summary>
public static IResult Unavailable(string reason)
=> Results.Json(new OutcomeDto(false, reason), statusCode: StatusCodes.Status503ServiceUnavailable);
// ─── Info und Zustand ───
private static void MapInfo(this RouteGroupBuilder api)
{
api.MapGet("/info", (AppHost host) => new InfoDto(
AppHost.AppVersion,
AppHost.BuildSummary,
host.Instance.InstanceId,
host.Instance.InstanceName,
EngineActive: host.Engine is not null,
SchedulerActive: host.Scanner is not null,
StagingActive: host.Staging is not null,
DeploymentcenterActive: host.Deploymentcenter is not null));
api.MapGet("/status", (AppHost host) =>
{
if (host.Status is not { } status)
return Unavailable("Kein OpenRouter-Schlüssel hinterlegt.");
return Results.Ok(new StatusDto(
status.IsApiReachable,
status.StatusText,
status.CreditsText,
status.CreditBalance,
status.CreditRemaining));
});
}
// ─── Agenten ───
private static void MapAgents(this RouteGroupBuilder api)
{
var agents = api.MapGroup("/agents");
agents.MapGet("/", (AppHost host) =>
{
var descriptions = host.AgentAdmin.ToolDescriptions();
return host.AgentAdmin.All
.Select(a => a.ToDto(descriptions, host.Engine?.IsRunning(a.AgentId) ?? false))
.ToList();
});
agents.MapGet("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Find(agentId) is { } agent
? Results.Ok(agent.ToDto(
host.AgentAdmin.ToolDescriptions(),
host.Engine?.IsRunning(agent.AgentId) ?? false))
: Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'.")));
agents.MapPost("/", (AppHost host, NewAgentDto input) =>
host.AgentAdmin.Add(new NewAgent(
input.AgentId, input.DisplayName, input.Model, input.Description)).ToResponse());
agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Remove(agentId).ToResponse());
agents.MapPut("/{agentId}/tools/{toolName}", (
AppHost host, string agentId, string toolName, bool enabled) =>
host.AgentAdmin.SetToolEnabled(agentId, toolName, enabled).ToResponse());
agents.MapGet("/{agentId}/tools/{toolName}/settings", (
AppHost host, string agentId, string toolName) =>
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'."));
var settings = agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>();
return Results.Ok(Mapping.ToDto(toolName, settings));
});
agents.MapPost("/{agentId}/run", async (
AppHost host, string agentId, CancellationToken ct) =>
(await host.Runs.RunAgentAsync(agentId, message: null, ct)).ToResponse());
}
// ─── Chat ───
private static void MapChat(this RouteGroupBuilder api)
{
var chat = api.MapGroup("/agents/{agentId}/chat");
chat.MapGet("/", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
return Results.Ok(engine.GetChatHistory(agentId).Select(e => e.ToDto()).ToList());
});
chat.MapPost("/", async (
AppHost host, string agentId, SendMessageDto input, CancellationToken ct) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
if (host.AgentAdmin.Find(agentId) is not { } agent)
return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'."));
var text = input.Message?.Trim() ?? "";
if (text.Length == 0)
return Results.BadRequest(new OutcomeDto(false, "Leere Nachricht."));
// Quelle mitgeben: OnChatEntryAdded reicht sie weiter, und das Fenster kann
// anzeigen, dass die Nachricht aus dem Browser kam. Die Engine reiht Laeufe
// desselben Agenten ohnehin hintereinander ein — zwei Oberflaechen
// gleichzeitig sind deshalb kein Sonderfall.
var result = await engine.ChatAsync(
agent, text, host.Instance.InstanceId, ct, source: Core.Engine.ChatSource.WebUi);
return Results.Ok(new OutcomeDto(
true, $"{agent.DisplayName} fertig ({result.Status}, {result.TokensUsed:N0} Tokens)."));
});
chat.MapPost("/abort", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
engine.AbortChat(agentId);
return Results.Ok(new OutcomeDto(true, "Chat abgebrochen."));
});
chat.MapDelete("/", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
engine.ClearChatHistory(agentId);
return Results.Ok(new OutcomeDto(true, "Verlauf geleert."));
});
}
// ─── Geplante Läufe ───
private static void MapJobs(this RouteGroupBuilder api)
{
var jobs = api.MapGroup("/jobs");
jobs.MapGet("/", (AppHost host) => host.JobAdmin.List()
.Select(j => j.ToDto(host.Runs.LastRun(j.JobId)))
.ToList());
jobs.MapPost("/", (AppHost host, JobInputDto input) =>
host.JobAdmin.Add(input.ToDefinition()).ToResponse());
jobs.MapPut("/{jobId}", (AppHost host, string jobId, JobInputDto input) =>
host.JobAdmin.Update(jobId, input.ToDefinition()).ToResponse());
jobs.MapDelete("/{jobId}", (AppHost host, string jobId, string agentId, string kind) =>
host.JobAdmin.Remove(
agentId,
jobId,
string.Equals(kind, "toolJob", StringComparison.OrdinalIgnoreCase)
? JobKind.ToolJob
: JobKind.AgentWakeup).ToResponse());
jobs.MapPut("/{jobId}/enabled", (
AppHost host, string jobId, string agentId, bool enabled) =>
host.JobAdmin.SetEnabled(agentId, jobId, enabled).ToResponse());
jobs.MapPost("/{jobId}/run", async (
AppHost host, string jobId, CancellationToken ct) =>
{
if (host.JobAdmin.List().FirstOrDefault(j => j.JobId == jobId) is not { } job)
return Results.NotFound(new OutcomeDto(false, $"Kein geplanter Lauf '{jobId}'."));
return (await host.Runs.RunAsync(job, ct)).ToResponse();
});
}
// ─── Dienste ───
private static void MapServices(this RouteGroupBuilder api)
{
var services = api.MapGroup("/services");
services.MapGet("/", (AppHost host) =>
host.ServiceAdmin.All.Select(s => s.ToDto()).ToList());
services.MapPost("/", (AppHost host, ServiceInputDto input) =>
host.ServiceAdmin.Add(new NewService(
input.Name, input.Type, input.Port, input.Description)).ToResponse());
services.MapDelete("/{serviceId}", (AppHost host, string serviceId) =>
host.ServiceAdmin.Remove(serviceId).ToResponse());
services.MapPut("/{serviceId}/enabled", (
AppHost host, string serviceId, bool enabled) =>
host.ServiceAdmin.SetEnabled(serviceId, enabled).ToResponse());
}
// ─── Taskboard ───
private static void MapTasks(this RouteGroupBuilder api)
{
var tasks = api.MapGroup("/tasks");
tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) =>
{
if (host.Board is not { } board)
return Unavailable(
"Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.");
var items = await board.ListAsync(
new Core.Tasks.TaskQuery { Limit = Math.Clamp(limit, 1, 500) }, ct);
return Results.Ok(items.Select(t => t.ToDto()).ToList());
});
tasks.MapGet("/{id}", async (AppHost host, string id, CancellationToken ct) =>
{
if (host.Board is not { } board)
return Unavailable("Kein Taskboard in dieser Instanz.");
return await board.GetAsync(id, ct) is { } task
? Results.Ok(task.ToDto())
: Results.NotFound(new OutcomeDto(false, $"Keine Aufgabe '{id}'."));
});
}
// ─── Freigaben ───
private static void MapStaging(this RouteGroupBuilder api)
{
var staging = api.MapGroup("/staging");
staging.MapGet("/", async (AppHost host, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var pending = await service.ListPendingAsync(ct);
return Results.Ok(pending.Select(c => c.ToDto()).ToList());
});
staging.MapPost("/{id:long}/approve", async (AppHost host, long id, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var result = await service.ApproveAsync(id, decidedBy: "webui", ct);
return Results.Ok(new OutcomeDto(
result.Kind is Core.Staging.StagingResultKind.Executed, result.Message));
});
staging.MapPost("/{id:long}/reject", async (
AppHost host, long id, RejectDto input, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var result = await service.RejectAsync(id, "webui", input.Reason ?? "", ct);
return Results.Ok(new OutcomeDto(
result.Kind is Core.Staging.StagingResultKind.Rejected, result.Message));
});
}
// ─── Verbrauch ───
private static void MapUsage(this RouteGroupBuilder api)
{
api.MapGet("/usage", async (AppHost host, CancellationToken ct, int limit = 100) =>
{
if (host.Usage is not { } usage)
return Unavailable("Keine Verbrauchsdaten: Agenten sind nicht aktiv.");
var recent = await usage.GetRecentAsync(Math.Clamp(limit, 1, 1000), ct);
return Results.Ok(recent.Select(r => r.ToDto()).ToList());
});
}
// ─── Protokoll ───
private static void MapLogs(this RouteGroupBuilder api)
{
api.MapGet("/logs/modules", (AppHost host) =>
new LogTail(host.LogDirectory).AvailableModules());
}
}