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; /// /// Die HTTP-Fläche der Weboberfläche. /// /// Jeder Endpunkt hier ist dünn: Er nimmt entgegen, ruft einen Dienst aus /// ClawdDotNet.App und formt das Ergebnis um. Eine Regel, die nur hier steht, /// ist eine an der falschen Stelle — 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. /// internal static class ApiEndpoints { public static void MapApi(this WebApplication app) { var api = app.MapGroup("/api"); api.MapInfo(); api.MapAgents(); api.MapChat(); api.MapServices(); api.MapTasks(); api.MapStaging(); api.MapUsage(); api.MapAnalytics(); api.MapLogs(); api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto()); app.MapEvents(); app.MapControl(); } /// /// Übersetzt ein in eine Antwort. /// /// 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. /// public static IResult ToResponse(this AdminResult result) => result.Ok ? Results.Ok(new OutcomeDto(true, result.Message)) : Results.BadRequest(new OutcomeDto(false, result.Message)); /// /// Für Bereiche, die es ohne Zutun nicht gibt: keine Engine ohne API-Schlüssel, kein /// Scanner ohne gemeinsamen Arbeitsbereich, kein Staging ohne beides. /// /// 503 mit einer Begründung statt einer NullReferenceException. Die /// Oberfläche kann daraus einen Satz machen; aus einem Absturz nicht. /// 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(); }); // ─── Interne Agenten-Kommunikation (A2A, #208) ─── agents.MapGet("/communications", (AppHost host) => { if (host.Engine is not { } engine) return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel)."); var list = AgentCommunicationBuilder.Build( host.AgentAdmin.All.Select(agent => ( agent.AgentId, agent.DisplayName, (IEnumerable)engine.GetChatHistory(agent.AgentId)))); return Results.Ok(list); }); 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(); return Results.Ok(Mapping.ToDto(toolName, settings)); }); // #641: Rechte je Werkzeug - was laeuft, was vorgelegt wird, was verboten ist. agents.MapGet("/{agentId}/tools/{toolName}/rights", async ( AppHost host, string agentId, string toolName, CancellationToken ct) => { if (host.AgentAdmin.Find(agentId) is not { } agent) return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'.")); if (!agent.Tools.TryGetValue(toolName, out var settings)) return Results.NotFound(new OutcomeDto(false, $"Werkzeug '{toolName}' ist nicht zugewiesen.")); if (host.Tools.Get(toolName) is not { } tool) return Results.NotFound(new OutcomeDto(false, $"Werkzeug '{toolName}' ist nicht installiert.")); var dauer = host.Staging is { } service ? (await service.ListDauerfreigabenAsync(false, ct)).Where(d => d.AgentId == agentId && d.Tool == toolName).ToList() : []; return Results.Ok(WerkzeugrechteAnsicht.Erstelle(tool, settings.AsReadOnly(), dauer)); }); agents.MapPut("/{agentId}/tools/{toolName}/rights", ( AppHost host, string agentId, string toolName, ToolRightsInputDto input) => host.AgentAdmin.SetToolRights(agentId, toolName, input.Allowed, input.Denied ?? []).ToResponse()); 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.")); }); } // ─── 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"); // #594: Das Taskboard ist die einzige Stelle fuer geplante Arbeit. Die frueheren // /api/jobs bearbeiteten die Alt-Konfiguration, die nach der Uebernahme nichts mehr bewirkte. tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) => await host.TaskAdmin.ListAsync(limit, ct) is { } list ? Results.Ok(list.Select(t => t.ToDto()).ToList()) : Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.")); tasks.MapPost("/", async (AppHost host, TaskInputDto input, CancellationToken ct) => (await host.TaskAdmin.CreateAsync(input.ToInput(), ct)).ToResponse()); tasks.MapPut("/{id}", async (AppHost host, string id, TaskInputDto input, CancellationToken ct) => (await host.TaskAdmin.UpdateAsync(id, input.ToInput(), ct)).ToResponse()); tasks.MapPut("/{id}/enabled", async (AppHost host, string id, bool enabled, CancellationToken ct) => (await host.TaskAdmin.SetEnabledAsync(id, enabled, ct)).ToResponse()); tasks.MapPost("/{id}/run", async (AppHost host, string id, CancellationToken ct) => (await host.TaskAdmin.RunNowAsync(id, ct)).ToResponse()); 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); var liste = new List(pending.Count); foreach (var call in pending) liste.Add(call.ToDto(await service.BisherFreigegebenAsync(call, ct), service.KenntDauerfreigaben)); return Results.Ok(liste); }); // #641: similar=true - "Gleichartiges kuenftig erlauben". staging.MapPost("/{id:long}/approve", async ( AppHost host, long id, CancellationToken ct, bool similar = false) => { if (host.Staging is not { } service) return Unavailable("Keine Freigabestelle in dieser Instanz."); var result = await service.ApproveAsync(id, decidedBy: "webui", gleichartigErlauben: similar, ct); return Results.Ok(new OutcomeDto( result.Kind is Core.Staging.StagingResultKind.Executed, result.Message)); }); staging.MapGet("/standing", async (AppHost host, CancellationToken ct, bool revoked = false) => { if (host.Staging is not { } service) return Unavailable("Keine Freigabestelle in dieser Instanz."); var liste = await service.ListDauerfreigabenAsync(revoked, ct); return Results.Ok(liste.Select(d => d.ToDto()).ToList()); }); staging.MapPost("/standing/{id:long}/revoke", async (AppHost host, long id, CancellationToken ct) => { if (host.Staging is not { } service) return Unavailable("Keine Freigabestelle in dieser Instanz."); var ok = await service.WiderrufenAsync(id, "webui", ct); return Results.Ok(new OutcomeDto(ok, ok ? $"Dauerfreigabe #{id} widerrufen. Die Operation wird wieder einzeln vorgelegt." : $"Dauerfreigabe #{id} gibt es nicht oder sie ist schon widerrufen.")); }); 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()); }); // #595: Hochrechnung aus Terminen und bisherigen Laeufen. api.MapGet("/usage/forecast", async (AppHost host, CancellationToken ct) => await host.TaskAdmin.ForecastAsync(ct) is { } prognose ? Results.Ok(prognose.ToDto()) : Unavailable("Keine Prognose: In dieser Instanz gibt es kein Taskboard.")); } // ─── Protokoll ─── private static void MapLogs(this RouteGroupBuilder api) { api.MapGet("/logs/modules", (AppHost host) => new LogTail(host.LogDirectory).AvailableModules()); } }