- 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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user