- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete), gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten, Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage - Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig, Selbstbearbeitung gesperrt (allowSelfEdit) - Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf, Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt - Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab, Token nicht mehr ins systemd-Journal Version 0.1.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
13 KiB
C#
331 lines
13 KiB
C#
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.MapServices();
|
|
api.MapTasks();
|
|
api.MapStaging();
|
|
api.MapUsage();
|
|
api.MapLogs();
|
|
|
|
app.MapEvents();
|
|
app.MapControl();
|
|
}
|
|
|
|
/// <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();
|
|
});
|
|
|
|
// ─── 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<Core.Engine.ChatEntry>)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<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."));
|
|
});
|
|
}
|
|
|
|
// ─── 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);
|
|
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());
|
|
});
|
|
|
|
// #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());
|
|
}
|
|
}
|