Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es, auf einen Blick. Stufe 0 kommt ohne Einheiten aus; mit #683 werden die Agenten zu aufklappbaren Containern, und die Ansicht faellt mit #688 zusammen. Kern: - AgentActivity: fuehrt die laufenden Laeufe (Beginn, Quelle, Aufgabe, Aufrufer) und die letzten Verbindungen (begrenzt auf 500), meldet Start, Ende und Verbindung als Ereignis. Vorher war nur ein Lauf ueber ChatAsync "laufend"; ein frischer Lauf (@new) war unsichtbar, und niemand hielt fest, wer wen gerufen hatte. - Die Engine meldet beide Einstiege an; "arbeitet" beginnt hinter dem Agententor. Anwendung: - TeamOverview stellt den Lagebericht zusammen. Die Zustaende entscheidet der Code aus erfassten Tatsachen: laufender Lauf, offene Freigabe, Status des letzten Belegs, Laufzeit gegen den Median des Agenten (haengt ab dem Dreifachen, fruehestens nach 5 Minuten, ohne Verlauf nach 15). Rangfolge: haengt, wartet auf Freigabe, arbeitet; Problem und Budget nur, solange nichts Neues laeuft. Web: - GET /api/team, Ereignis "activity" (started, finished, link); das Ereignis "run" traegt jetzt die Agenten-Id. - Ansicht #/team mit Cytoscape.js und ELK (bei Bedarf geladen): Layout aus der Struktur statt von Hand, Farbe und Symbol je Zustand, Kanten verblassen nach 30 s, Klick oeffnet eine Seitenleiste (laufende Laeufe, Freigaben, letzter Lauf im Wortlaut) mit "Freigaben oeffnen" vorne, danach Chat und Laeufe. Sprachpakete en/de. - Ein Layout, das lief, solange die Flaeche noch 0 px breit war, wird neu gerechnet, sobald sie eine Breite hat (im Pruefgestell gefunden). Geprueft im Browser mit einem Pruefgestell (API und Ereignisstrom durch Attrappen ersetzt): sieben Agenten in allen Zustaenden, Seitenleiste, Live-Kante und Verblassen. Tests: AgentActivityTests (8), TeamOverviewTests (14). 1.283 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
379 lines
16 KiB
C#
379 lines
16 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.MapAnalytics();
|
|
api.MapLogs();
|
|
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
|
|
|
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));
|
|
});
|
|
|
|
// #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<StagedCallDto>(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());
|
|
}
|
|
}
|