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,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Web</RootNamespace>
</PropertyGroup>
<!-- Bewusst Microsoft.NET.Sdk und nicht Microsoft.NET.Sdk.Web: Dieses Projekt ist eine
Bibliothek, die in einem fremden Prozess einen Kestrel hochfaehrt. Das Web-SDK
braechte einen eigenen Einstiegspunkt und den Manifest-Mechanismus fuer statische
Ressourcen mit - beides waere hier im Weg, weil veroeffentlicht wird, was
ClawdDotNet.Desktop veroeffentlicht.
ASP.NET Core kommt als Framework-Referenz und damit ohne NuGet-Paket. Das ist auch
der Grund, warum NuGet.Config unveraendert bleibt. -->
<ItemGroup>
<InternalsVisibleTo Include="ClawdDotNet.Web.Tests" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Die Weboberflaeche als gewoehnliche Dateien neben der Programmdatei, nicht
eingebettet. Zwei Gruende: Wer am HTML arbeitet, sieht seine Aenderung nach einem
Neuladen im Browser statt nach einem Neubau - und eingebettete Ressourcen tragen
den Ordnernamen als Punkt im Namen, was bei jedem Bindestrich und jedem
Unterverzeichnis eine eigene kleine Falle ist.
Content mit CopyToOutputDirectory wandert ueber die Projektreferenz in die Ausgabe
von ClawdDotNet.Desktop und von dort ins Paket. Geprueft mit einem echten
dotnet publish, nicht angenommen. -->
<ItemGroup>
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
</ItemGroup>
</Project>
+177
View File
@@ -0,0 +1,177 @@
namespace ClawdDotNet.Web.Contracts;
// ─────────────────────────────────────────────────────────────────────────────
// Die Übertragungstypen der Weboberfläche.
//
// Warum es sie gibt, statt die Kerntypen zu serialisieren:
//
// 1. AgentConfig trägt Zugangsdaten. Ein `return agent;` hätte den
// Werkzeug-Konfigurationsbaum samt Passwörtern ins Netz geschickt — die Sorte
// Fehler, die niemandem auffällt, weil alles funktioniert.
// 2. Die Kerntypen ändern sich mit dem Kern. Ein neues Feld in TaskItem wäre sonst
// still eine API-Änderung, auf die sich jemand verlassen hat.
// 3. Die Grenze soll einen Prozesswechsel überstehen (siehe WebUiHost). Über eine
// Prozessgrenze geht nur, was hier steht.
//
// Namen auf Englisch in camelCase wie im übrigen Code; die Beschreibungen auf Deutsch.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>Antwort auf einen verwaltenden Aufruf — dieselbe Form wie <c>AdminResult</c>.</summary>
public sealed record OutcomeDto(bool Ok, string Message);
/// <summary>Version, Bau und Instanz. Was auf der Info-Seite steht.</summary>
public sealed record InfoDto(
string AppVersion,
string BuildSummary,
string InstanceId,
string InstanceName,
bool EngineActive,
bool SchedulerActive,
bool StagingActive,
bool DeploymentcenterActive);
/// <summary>Erreichbarkeit und Guthaben bei OpenRouter.</summary>
public sealed record StatusDto(
bool ApiReachable,
string StatusText,
string CreditsText,
double? CreditBalance,
double? CreditRemaining);
/// <summary>Ein Agent, ohne alles, was ein Zugangsdatum sein könnte.</summary>
public sealed record AgentDto(
string AgentId,
string DisplayName,
string Model,
string Description,
string PromptCaching,
int MaxToolResultChars,
int MaxSteps,
int MaxContextTokens,
int TimeoutSeconds,
decimal DailyCostUsd,
long DailyTokens,
bool IsRunning,
IReadOnlyList<AgentToolDto> Tools);
/// <summary>
/// Ein Werkzeug am Agenten. <b>Ohne die Einstellungen</b> — die stehen in einem freien
/// Wörterbuch und enthalten regelmäßig Passwörter und Schlüssel. Was einstellbar ist und
/// ob etwas gesetzt ist, sagt <see cref="ToolSettingsDto"/>.
/// </summary>
public sealed record AgentToolDto(string Name, string Description, bool Enabled);
/// <summary>
/// Die Einstellungen eines Werkzeugs, maskiert.
///
/// <para>Ein Wert, dessen Name ein Zugangsdatum bezeichnet, kommt nie heraus. Statt
/// seiner steht in <see cref="Secrets"/>, ob überhaupt etwas gesetzt ist — mehr braucht
/// eine Anzeige nicht, und mehr darf sie nicht wissen.</para>
/// </summary>
public sealed record ToolSettingsDto(
string Name,
IReadOnlyDictionary<string, object?> Values,
IReadOnlyDictionary<string, bool> Secrets);
/// <summary>Eine Zeile im Chatverlauf.</summary>
public sealed record ChatEntryDto(
string Role,
string Content,
DateTime Timestamp,
string? Source);
/// <summary>Was der Browser schickt, wenn jemand etwas in den Chat tippt.</summary>
public sealed record SendMessageDto(string Message);
/// <summary>Ein geplanter Lauf.</summary>
public sealed record JobDto(
string Kind,
string JobId,
string AgentId,
string AgentName,
string ToolName,
string Cron,
string Message,
bool RunOnStart,
DateTime? NextRunUtc,
DateTime? LastRunLocal,
bool CronValid,
bool Enabled);
/// <summary>Was einen geplanten Lauf ausmacht — beim Anlegen wie beim Ändern.</summary>
public sealed record JobInputDto(
string AgentId,
string Kind,
string ToolName = "",
string JobTypeId = "",
string Cron = "",
string TaskMessage = "",
bool RunOnStart = false);
/// <summary>Ein Dienst der Instanz.</summary>
public sealed record ServiceDto(
string ServiceId,
string Name,
string Type,
int Port,
bool Enabled,
bool BuiltIn,
string Description);
/// <summary>Was einen neuen Dienst ausmacht.</summary>
public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = "");
/// <summary>Ein neuer Agent.</summary>
public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "");
/// <summary>Eine Aufgabe vom Taskboard.</summary>
public sealed record TaskDto(
string Id,
string Title,
string Status,
string Type,
int Priority,
string Assignee,
bool RequireApproval,
string Acceptance,
IReadOnlyList<string> BlockedBy,
string Body,
DateTime CreatedAt,
DateTime UpdatedAt);
/// <summary>Ein eingefrorener Aufruf, der auf Freigabe wartet.</summary>
public sealed record StagedCallDto(
long Id,
string RunId,
string AgentId,
string Tool,
string? Action,
string Proposal,
string Status,
string ArgumentsJson);
/// <summary>Warum ein Vorschlag abgelehnt wurde.</summary>
public sealed record RejectDto(string Reason);
/// <summary>
/// Ein Lauf mit dem, was er gekostet hat.
///
/// <para><see cref="CostIsKnown"/> ist nicht schmückendes Beiwerk: Ohne geladene
/// Preisliste steht in <see cref="CostUsd"/> eine Null, die „kostenlos" heißen könnte
/// und „unbekannt" heißt. Eine Anzeige, die das nicht unterscheidet, lügt.</para>
/// </summary>
public sealed record UsageDto(
long Id,
string AgentId,
string Model,
int PromptTokens,
int CompletionTokens,
int CachedTokens,
decimal CostUsd,
bool CostIsKnown,
string Status,
int StepCount,
DateTime OccurredAt);
/// <summary>Eine Protokollzeile.</summary>
public sealed record LogLineDto(string Module, string Level, string Text);
+156
View File
@@ -0,0 +1,156 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Web.Contracts;
/// <summary>
/// Die eine Stelle, an der Kerntypen zu Übertragungstypen werden.
///
/// <para>Eine Stelle, weil die Maskierung von Zugangsdaten hier hängt. Verstreut über
/// zehn Endpunkte wäre die Frage „kommt hier ein Passwort heraus?" nicht mehr zu
/// beantworten, sondern nur noch zu hoffen.</para>
/// </summary>
internal static class Mapping
{
public static AgentDto ToDto(
this AgentConfig agent,
IReadOnlyDictionary<string, string> toolDescriptions,
bool isRunning)
=> new(
agent.AgentId,
agent.DisplayName,
agent.Model,
agent.Description,
agent.PromptCaching,
agent.MaxToolResultChars,
agent.LoopGuard.MaxSteps,
agent.LoopGuard.MaxContextTokens,
agent.LoopGuard.TimeoutSeconds,
agent.Budget.DailyCostUsd,
agent.Budget.DailyTokens,
isRunning,
AgentAdministration.KnownTools
.Select(name => new AgentToolDto(
name,
toolDescriptions.GetValueOrDefault(name) ?? "",
agent.Tools.ContainsKey(name)))
.ToList());
/// <summary>
/// Die Einstellungen eines Werkzeugs — <b>ohne die Geheimnisse</b>.
///
/// <para>Welcher Schlüssel eines ist, entscheidet <see cref="ConfigSecrets"/>: der
/// Kern weiß es bereits, und eine zweite Liste hier wäre eine, die beim nächsten
/// neuen Werkzeug vergessen wird. Statt des Werts steht in <c>Secrets</c>, ob
/// überhaupt einer gesetzt ist.</para>
/// </summary>
public static ToolSettingsDto ToDto(
string toolName, IReadOnlyDictionary<string, object?> settings)
{
var values = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
var secrets = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in settings)
{
if (ConfigSecrets.IsSecretKey(key))
secrets[key] = value is not null && value is not string { Length: 0 };
else
values[key] = value;
}
return new ToolSettingsDto(toolName, values, secrets);
}
public static ChatEntryDto ToDto(this ChatEntry entry)
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
public static JobDto ToDto(this JobSummary job, DateTime? lastRun)
=> new(
job.Kind == JobKind.ToolJob ? "toolJob" : "agentWakeup",
job.JobId,
job.AgentId,
job.AgentName,
job.ToolName,
job.Cron,
job.Message,
job.RunOnStart,
job.NextRunUtc,
lastRun,
job.CronValid,
job.Enabled);
public static JobDefinition ToDefinition(this JobInputDto input)
=> new(
input.AgentId,
string.Equals(input.Kind, "toolJob", StringComparison.OrdinalIgnoreCase)
? JobKind.ToolJob
: JobKind.AgentWakeup,
input.ToolName,
input.JobTypeId,
input.Cron,
input.TaskMessage,
input.RunOnStart);
public static ServiceDto ToDto(this ServiceConfig service)
=> new(
service.ServiceId,
service.Name,
service.Type,
service.Port,
service.Enabled,
service.BuiltIn,
service.Description);
public static TaskDto ToDto(this TaskItem task)
=> new(
task.Id,
task.Title,
task.Status.ToString(),
task.Type.ToString(),
task.Priority,
task.Assignee,
task.RequireApproval,
task.Acceptance,
task.BlockedBy,
task.Body,
task.CreatedAt,
task.UpdatedAt);
/// <summary>
/// Ein wartender Aufruf samt seiner eingefrorenen Argumente.
///
/// <para>Die Argumente gehen <b>roh</b> heraus, und das ist der ganze Sinn der
/// Freigabe: Wer entscheidet, muss sehen, was genau ausgeführt würde — nicht eine
/// Zusammenfassung davon. Sie sind zugleich Fremdtext aus einem Modell; die Anzeige
/// setzt sie als Text ein, nie als HTML.</para>
/// </summary>
public static StagedCallDto ToDto(this StagedCall call)
=> new(
call.Id,
call.RunId,
call.AgentId,
call.Tool,
call.Action,
call.Proposal,
call.Status.ToString(),
call.ArgumentsJson);
public static UsageDto ToDto(this RunUsage usage)
=> new(
usage.Id,
usage.AgentId,
usage.Model,
usage.PromptTokens,
usage.CompletionTokens,
usage.CachedTokens,
usage.CostUsd,
usage.CostIsKnown,
usage.Status,
usage.StepCount,
usage.OccurredAt);
}
@@ -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());
}
}
@@ -0,0 +1,231 @@
using System.Text.Json;
using System.Threading.Channels;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Der Ereignisstrom: was im Kern passiert, während niemand danach fragt.
///
/// <para><b>Server-Sent Events und nicht WebSocket.</b> Die Richtung ist einseitig —
/// Befehle gehen über gewöhnliche POSTs —, SSE übersteht Zwischenschichten, bringt das
/// Wiederverbinden mit und braucht auf keiner Seite eine zusätzliche Bibliothek.</para>
///
/// <para><b>Die Ereignisse kommen aus Hintergrundfäden.</b> <c>AgentEngine</c> löst sie
/// dort aus, wo der Lauf gerade ist. Deshalb geht hier nichts direkt in die Antwort,
/// sondern erst in einen Kanal, den die Anfrage abarbeitet: Ein Ereignisbehandler, der in
/// den Antwortstrom schreibt, träfe früher oder später auf einen zweiten, der dasselbe
/// tut, und ein <c>HttpResponse</c> verträgt keine zwei Schreiber.</para>
/// </summary>
internal static class EventEndpoints
{
/// <summary>
/// Wie viele Ereignisse ein langsamer Browser hinter sich herziehen darf, bevor die
/// ältesten wegfallen.
///
/// <para>Begrenzt und verwerfend, nicht unbegrenzt: Ein Tab, der im Hintergrund
/// eingefroren ist, würde sonst den Speicher des Prozesses füllen, in dem die
/// Agenten laufen. Ein verlorener Zwischenstand ist der bessere Verlust — die Seite
/// lädt den Verlauf beim Verbinden ohnehin neu.</para>
/// </summary>
private const int QueueDepth = 256;
public static void MapEvents(this WebApplication app)
{
var events = app.MapGroup("/events");
events.MapGet("/", StreamCoreEvents);
events.MapGet("/logs", StreamLogs);
}
private static async Task StreamCoreEvents(
HttpContext context, AppHost host, CancellationToken ct)
{
var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true
});
void Publish(string type, object payload)
=> queue.Writer.TryWrite(Format(type, payload));
// Die Behandler stehen als benannte Delegaten da, weil sie im finally wieder
// abgemeldet werden muessen. Ein Lambda direkt am += liesse sich nicht mehr
// entfernen, und nach dreissig Neuladen der Seite liefe jede Nachricht
// dreissigmal durch.
void OnChat(string agentId, string role, string content, string? source)
=> Publish("chat", new { agentId, role, content, source, timestamp = DateTime.UtcNow });
void OnStep(string agentId, string text)
=> Publish("step", new { agentId, text });
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
=> Publish("run", new
{
model,
status = result.Status.ToString(),
steps = result.StepCount,
tokens = result.TokensUsed
});
void OnInstanceChanged(InstanceChange change)
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
void OnStatus()
=> Publish("status", new { });
if (host.Engine is { } engine)
{
engine.OnChatEntryAdded += OnChat;
engine.OnStepCompleted += OnStep;
engine.OnRunCompleted += OnRunCompleted;
}
host.InstanceChanged += OnInstanceChanged;
if (host.Status is { } status)
status.OnStatusUpdated += OnStatus;
try
{
await WriteStream(context, queue.Reader, ct);
}
finally
{
if (host.Engine is { } e)
{
e.OnChatEntryAdded -= OnChat;
e.OnStepCompleted -= OnStep;
e.OnRunCompleted -= OnRunCompleted;
}
host.InstanceChanged -= OnInstanceChanged;
if (host.Status is { } s)
s.OnStatusUpdated -= OnStatus;
queue.Writer.TryComplete();
}
}
/// <summary>
/// Das Protokoll, fortlaufend.
///
/// <para><b>Eine eigene <see cref="LogTail"/> je Verbindung</b>, und das ist keine
/// Sparsamkeitsfrage: <c>ReadNew</c> liefert die Zeilen seit dem letzten Aufruf und
/// merkt sich die Stelle. Eine geteilte Ausprägung hieße, dass der erste Browser die
/// Zeilen bekommt und alle anderen nichts.</para>
/// </summary>
private static async Task StreamLogs(
HttpContext context, AppHost host, CancellationToken ct, string? module = null, string? level = null)
{
var tail = new LogTail(host.LogDirectory);
var filter = Enum.TryParse<LogLevelFilter>(level, ignoreCase: true, out var parsed)
? parsed
: LogLevelFilter.All;
var interval = TimeSpan.FromMilliseconds(
Math.Clamp(host.Settings.AppSettings.LogRefreshIntervalMs, 200, 5000));
PrepareResponse(context);
using var timer = new PeriodicTimer(interval);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
var lines = tail.ReadNew(module, filter);
if (lines.Count == 0)
{
// Ein Kommentar haelt die Verbindung offen, ohne ein Ereignis
// vorzutaeuschen. Ohne ihn kappt so mancher Proxy nach einer Minute.
await context.Response.WriteAsync(": still\n\n", ct);
}
else
{
foreach (var line in lines)
await context.Response.WriteAsync(
Format("log", new LogLineDto(line.Module, line.Level, line.Text)), ct);
}
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt. Der einzige normale Weg, auf dem dieser Strom endet.
}
}
private static async Task WriteStream(
HttpContext context, ChannelReader<string> reader, CancellationToken ct)
{
PrepareResponse(context);
// Sofort etwas schicken: Erst damit steht die Verbindung fuer den Browser, und
// onopen feuert. Ohne das haengt die Seite im Ungewissen, bis das erste Ereignis
// von selbst kommt — was Stunden dauern kann.
await context.Response.WriteAsync(": bereit\n\n", ct);
await context.Response.Body.FlushAsync(ct);
var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
try
{
var next = reader.ReadAsync(ct).AsTask();
var beat = heartbeat.WaitForNextTickAsync(ct).AsTask();
while (true)
{
var done = await Task.WhenAny(next, beat);
if (done == next)
{
await context.Response.WriteAsync(await next, ct);
next = reader.ReadAsync(ct).AsTask();
}
else
{
if (!await beat) break;
await context.Response.WriteAsync(": still\n\n", ct);
beat = heartbeat.WaitForNextTickAsync(ct).AsTask();
}
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt.
}
catch (ChannelClosedException)
{
// Der Strom wird abgeraeumt.
}
finally
{
heartbeat.Dispose();
}
}
private static void PrepareResponse(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
// Ohne das puffert ein vorgelagerter nginx den Strom und liefert ihn erst, wenn
// er voll ist — aus einem Ereignisstrom wird dann eine Ereignissammlung.
context.Response.Headers["X-Accel-Buffering"] = "no";
}
private static string Format(string type, object payload)
=> $"event: {type}\ndata: {JsonSerializer.Serialize(payload)}\n\n";
}
+75
View File
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Web.Security;
/// <summary>
/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
///
/// <para><b>Was ohne Token ausgeliefert wird, ist die leere Hülle</b> — HTML, CSS und
/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert:
/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die
/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte.
/// In den Dateien steht nichts, was jemand nicht wissen dürfte — die Daten kommen
/// ausschließlich über <c>/api</c> und <c>/events</c>, und die sind gesperrt.</para>
///
/// <para><b>Warum der Vergleich in fester Zeit läuft:</b> Ein gewöhnlicher
/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst,
/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der
/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand.</para>
/// </summary>
public sealed class TokenGate(byte[] expected)
{
public static TokenGate For(string token)
=> new(Encoding.UTF8.GetBytes(token));
/// <summary>
/// Ob dieser Pfad ein Token braucht. Alles unterhalb von <c>/api</c> und
/// <c>/events</c>, sonst nichts.
/// </summary>
public static bool NeedsToken(PathString path)
=> path.StartsWithSegments("/api") || path.StartsWithSegments("/events");
/// <summary>
/// Holt das Token aus der Anfrage.
///
/// <para><c>Authorization: Bearer …</c> ist der Weg. Ein Cookie wäre bequemer und
/// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit,
/// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die
/// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier
/// keine eigene Baustelle.</para>
///
/// <para>Die Ausnahme ist der Ereignisstrom: <c>EventSource</c> im Browser kann keine
/// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der
/// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit
/// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in
/// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse
/// Proxy davor und entscheidet dort, was protokolliert wird.</para>
/// </summary>
public static string? ReadToken(HttpRequest request)
{
var header = request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..].Trim();
if (request.Path.StartsWithSegments("/events")
&& request.Query.TryGetValue("token", out var fromQuery))
return fromQuery.ToString();
return null;
}
public bool Matches(string? candidate)
{
if (string.IsNullOrEmpty(candidate)) return false;
// FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass
// dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis,
// sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit
// verglichen.
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(candidate), expected);
}
}
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Web;
/// <summary>
/// Reicht die Protokollierung des Webhosts an die Protokollierung der Anwendung durch.
///
/// <para>Ohne diese Zwischenschicht gäbe es zwei Möglichkeiten, und beide wären falsch:
/// Entweder der Webhost bekommt eigene Ausgabewege — dann stünde die Hälfte der
/// Meldungen woanders als die andere. Oder man reicht ihm die <c>ILoggerFactory</c> der
/// Anwendung direkt — dann räumt er sie beim Herunterfahren mit ab, und der Rest der
/// Anwendung protokolliert ab da ins Leere.</para>
///
/// <para>Deshalb: durchreichen, und beim Abräumen <b>nichts</b> tun. Die Fabrik gehört
/// dem <c>AppHost</c>, und der räumt sie selbst ab.</para>
/// </summary>
internal sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => shared.CreateLogger(categoryName);
public void Dispose()
{
// Absicht. Siehe Klassenbeschreibung.
}
}
+224
View File
@@ -0,0 +1,224 @@
using ClawdDotNet.App;
using ClawdDotNet.Web.Endpoints;
using ClawdDotNet.Web.Security;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Web;
/// <summary>
/// Die Weboberfläche, im Prozess der Anwendung.
///
/// <para><b>Im selben Prozess, und das ist keine Bequemlichkeit.</b> Die
/// <c>AgentEngine</c> hält Chatverlauf und Kontext je Agent im Arbeitsspeicher und
/// serialisiert Läufe über Semaphore je Agent; <c>SqliteStorage</c> serialisiert
/// Schreibvorgänge prozessweit. Ein zweiter Prozess hätte eine zweite Engine und keine
/// gemeinsame Sperre — das Ergebnis wäre keine Verlangsamung, sondern eine ungültige
/// <c>tool_call</c>-Sequenz und HTTP 400 mitten im Lauf.</para>
///
/// <para>Die Trennung „Kern als Dienst, Oberfläche als Client" steht auf der Roadmap als
/// offene Entscheidung. Diese Klasse trifft sie nicht — aber die HTTP-Fläche darunter
/// ist so geschnitten, dass sie einen Prozesswechsel überstünde: keine Objektidentität
/// über Aufrufe hinweg, alles über Kennungen und eigene Übertragungstypen.</para>
/// </summary>
public sealed class WebUiHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly ILogger _logger;
private WebUiHost(WebApplication app, ILogger logger, string url)
{
_app = app;
_logger = logger;
Url = url;
}
/// <summary>Die Adresse, unter der die Oberfläche hört.</summary>
public string Url { get; }
/// <summary>
/// Fährt die Weboberfläche hoch, sofern sie eingeschaltet und richtig eingestellt
/// ist.
///
/// <para><b>Gibt in jedem Fehlerfall <c>null</c> zurück und wirft nicht.</b> Eine
/// Anwendung, deren Fenster nicht aufgeht, weil ein Webserver keine Adresse bekommen
/// hat, wäre genau dann im Weg, wenn man sie braucht. Was schiefging, steht im
/// Protokoll.</para>
/// </summary>
public static WebUiHost? TryStart(AppHost host, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Web");
var options = WebUiOptions.TryCreate(host.Instance, out var problem);
if (options is null)
{
if (problem is not null)
logger.LogWarning("Weboberfläche nicht gestartet: {Grund}", problem);
return null;
}
try
{
var app = Build(host, loggerFactory, options);
// Nicht abwarten: StartAsync bindet den Port und kehrt zurueck; blockierend
// wuerde hier der Faden haengen, auf dem gleich das Fenster entsteht.
app.StartAsync().GetAwaiter().GetResult();
logger.LogInformation(
"Weboberfläche hört auf {Url} ({Bindung})",
options.DisplayUrl,
options.LoopbackOnly ? "nur lokal" : "alle Adressen");
return new WebUiHost(app, logger, options.DisplayUrl);
}
catch (Exception ex)
{
// Der haeufigste Fall ist ein belegter Port - eine zweite Instanz, ein
// vergessener Prozess. Das ist ein Grund, ohne Weboberflaeche zu laufen, und
// keiner, die Anwendung nicht zu starten.
logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", options.Url);
return null;
}
}
private static WebApplication Build(
AppHost host, ILoggerFactory loggerFactory, WebUiOptions options)
{
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
ContentRootPath = AppContext.BaseDirectory,
EnvironmentName = Environments.Production,
ApplicationName = typeof(WebUiHost).Assembly.GetName().Name
});
// Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen.
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
builder.WebHost.UseUrls(options.Url);
builder.Services.AddSingleton(host);
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(TokenGate.For(options.Token));
var app = builder.Build();
// Reihenfolge ist hier die halbe Miete:
// 1. Fehlerbehandlung ganz aussen, damit sie auch das Folgende einfaengt.
// 2. Torwaechter vor allem, was Daten liefert.
// 3. Statische Dateien - die leere Huelle, bewusst ohne Token.
// 4. Endpunkte.
app.Use(ReportUnhandled(host, loggerFactory));
app.Use(CheckToken);
MapStaticFiles(app);
app.MapApi();
return app;
}
/// <summary>
/// Fängt ab, was in einem Endpunkt durchrutscht: meldet es an den Fehler-Stream und
/// antwortet mit 500.
///
/// <para>Dasselbe, was <c>App.axaml.cs</c> für die Oberfläche tut — nur von innen.
/// Ohne das nähme eine Ausnahme in einem Endpunkt im ungünstigen Fall den ganzen
/// Prozess mit, und mit ihm die Agenten.</para>
/// </summary>
private static Func<HttpContext, RequestDelegate, Task> ReportUnhandled(
AppHost host, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Web");
return async (context, next) =>
{
try
{
await next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// Der Browser hat aufgelegt. Das ist kein Fehler und schon gar keiner,
// der ins Deploymentcenter gehoert.
}
catch (Exception ex)
{
logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path);
_ = host.Errors.ReportAsync(ex, fatal: false);
if (context.Response.HasStarted)
return;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { fehler = "Interner Fehler." });
}
};
}
private static async Task CheckToken(HttpContext context, RequestDelegate next)
{
if (!TokenGate.NeedsToken(context.Request.Path))
{
await next(context);
return;
}
var gate = context.RequestServices.GetRequiredService<TokenGate>();
if (!gate.Matches(TokenGate.ReadToken(context.Request)))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." });
return;
}
await next(context);
}
/// <summary>
/// Liefert die Oberfläche aus <c>wwwroot</c> neben der Programmdatei.
///
/// <para>Fehlt der Ordner, läuft die API trotzdem — nützlich, solange an der
/// Oberfläche noch gebaut wird, und ehrlicher als ein Absturz beim Start.</para>
/// </summary>
private static void MapStaticFiles(WebApplication app)
{
var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(webRoot))
{
app.Logger.LogWarning(
"Kein wwwroot unter {Pfad} — es läuft nur die API.", webRoot);
return;
}
var files = new PhysicalFileProvider(webRoot);
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
}
public async ValueTask DisposeAsync()
{
try
{
// Erst hier herunterfahren, dann der AppHost: Andersherum bediente Kestrel
// noch Anfragen gegen eine bereits geschlossene Datenbank.
await _app.StopAsync(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden");
}
await _app.DisposeAsync();
}
}
+75
View File
@@ -0,0 +1,75 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Web;
/// <summary>
/// Die geprüften Startangaben der Weboberfläche.
///
/// <para>Getrennt von <see cref="InstanceConfig"/>, weil dort Wünsche stehen und hier
/// Entscheidungen: Der Port ist eine Zahl, die Bindung eine von zweien, und ein Token,
/// das nicht taugt, ist gar keins. Was hier nicht durchkommt, kommt gar nicht erst an
/// den Kestrel.</para>
/// </summary>
public sealed record WebUiOptions(string Url, string Token, bool LoopbackOnly)
{
/// <summary>Kürzer als das hier darf ein Token nicht sein.</summary>
public const int MinimumTokenLength = 20;
/// <summary>
/// Prüft die Angaben der Instanz.
///
/// <para>Gibt <c>null</c> zurück, wenn die Weboberfläche abgeschaltet ist — das ist
/// kein Fehler und die Vorbelegung. <paramref name="problem"/> trägt den Grund,
/// wenn sie eingeschaltet ist, aber nicht starten darf.</para>
///
/// <para><b>Die harte Regel steht hier:</b> Bindung an alle Adressen ohne Token wird
/// abgelehnt, nicht bloß bemängelt. Ein Netzwerkdienst, der Agenten mit Datei-,
/// Mail- und FTP-Zugriff steuert, ist nichts, was versehentlich offen stehen darf.
/// Auch auf Loopback gilt Token-Pflicht: Auf einer Maschine, auf der Agenten
/// Browser-Werkzeuge bedienen, ist „localhost" keine Vertrauensgrenze.</para>
/// </summary>
public static WebUiOptions? TryCreate(InstanceConfig instance, out string? problem)
{
problem = null;
if (!instance.WebUiEnabled)
return null;
var loopbackOnly = !string.Equals(
instance.WebUiBinding, "any", StringComparison.OrdinalIgnoreCase);
var port = instance.WebServerPort;
if (port is < 1 or > 65535)
{
problem = $"Der Port {port} ist keiner. Erwartet wird 1 bis 65535.";
return null;
}
var token = instance.WebUiToken?.Trim() ?? "";
if (token.Length == 0)
{
problem = loopbackOnly
? "Die Weboberfläche braucht ein Zugangstoken (webUiToken in der Instanzdatei)."
: "Die Weboberfläche ist auf alle Adressen gebunden und hat kein Zugangstoken. "
+ "So wird sie nicht gestartet.";
return null;
}
if (token.Length < MinimumTokenLength)
{
problem = $"Das Zugangstoken ist zu kurz — mindestens {MinimumTokenLength} Zeichen.";
return null;
}
var host = loopbackOnly ? "127.0.0.1" : "0.0.0.0";
return new WebUiOptions($"http://{host}:{port}", token, loopbackOnly);
}
/// <summary>
/// Die Adresse, die man einem Menschen zeigt. <c>0.0.0.0</c> ist keine, die sich
/// anklicken lässt.
/// </summary>
public string DisplayUrl => LoopbackOnly ? Url : Url.Replace("0.0.0.0", Environment.MachineName);
}
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
// ClawdDotNet — WebUI Haupteinstiegspunkt
//
// Initialisiert Authentifizierung, Router, Ereignisstrom, Farbschema und Kopfzeile.
import { api, hatToken, loescheToken, beiUnberechtigt } from "./modules/api.js";
import { verbindeStrom, trenneStrom, beiVerbindungsStatus, abonnieren } from "./modules/events.js";
import { initialisiereTheme, wechsleTheme } from "./modules/theme.js";
import { registriereRoute, initialisiereRouter, navigiere } from "./modules/router.js";
import { initialisiereLogin, zeigeAnmeldung, versteckeAnmeldung } from "./views/login.js";
// Ansichten
import { rendereChat } from "./views/chat.js";
import { rendereAgenten } from "./views/agents.js";
import { rendereAufgaben } from "./views/tasks.js";
import { rendereDienste } from "./views/services.js";
import { rendereStaging, holeWartendeAnzahl } from "./views/staging.js";
import { rendereVerbrauch } from "./views/usage.js";
import { rendereProtokoll, trenneLogStream } from "./views/logs.js";
import { rendereInfo } from "./views/info.js";
import { rendereSicherung, rendereEinstellungen, rendereAgentenChats } from "./views/placeholders.js";
// ─── 1. Routen registrieren ───
registriereRoute("/chat", () => { trenneLogStream(); return rendereChat(); });
registriereRoute("/chat/:agentId", (p) => { trenneLogStream(); return rendereChat(p); });
registriereRoute("/agenten", () => { trenneLogStream(); return rendereAgenten(); });
registriereRoute("/agenten/:agentId", (p) => { trenneLogStream(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { trenneLogStream(); return rendereAufgaben(); });
registriereRoute("/dienste", () => { trenneLogStream(); return rendereDienste(); });
registriereRoute("/staging", () => { trenneLogStream(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { trenneLogStream(); return rendereVerbrauch(); });
registriereRoute("/agenten-chats", () => { trenneLogStream(); return rendereAgentenChats(); });
registriereRoute("/protokoll", () => rendereProtokoll());
registriereRoute("/sicherung", () => { trenneLogStream(); return rendereSicherung(); });
registriereRoute("/einstellungen", () => { trenneLogStream(); return rendereEinstellungen(); });
registriereRoute("/info", () => { trenneLogStream(); return rendereInfo(); });
// ─── 2. DOM-Verdrahtung ───
const seitenleiste = document.getElementById("seitenleiste");
const sidebarToggle = document.getElementById("sidebar-toggle");
const mobilMenueBtn = document.getElementById("mobil-menue-btn");
const seitenleisteBackdrop = document.getElementById("seitenleiste-backdrop");
const themeSchalter = document.getElementById("theme-schalter");
const abmeldenBtn = document.getElementById("abmelden-btn");
const verbindungPunkt = document.getElementById("verbindung-punkt");
const kopfInstanz = document.getElementById("kopf-instanz");
const kopfOpenRouter = document.getElementById("kopf-openrouter-status");
// Seitenleiste kollabieren (Desktop)
sidebarToggle?.addEventListener("click", () => {
seitenleiste?.classList.toggle("kollabiert");
});
// Mobil-Menü öffnen
mobilMenueBtn?.addEventListener("click", () => {
seitenleiste?.classList.add("mobil-offen");
if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = false;
});
// Mobil-Menü schließen bei Klick auf Backdrop oder Link
function schliesseMobilMenue() {
seitenleiste?.classList.remove("mobil-offen");
if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = true;
}
seitenleisteBackdrop?.addEventListener("click", schliesseMobilMenue);
document.querySelectorAll(".nav-link").forEach((link) => {
link.addEventListener("click", () => {
if (window.innerWidth <= 768) schliesseMobilMenue();
});
});
// Farbschema umschalten
themeSchalter?.addEventListener("click", () => {
wechsleTheme();
});
// Abmelden
abmeldenBtn?.addEventListener("click", () => {
loescheToken();
trenneStrom();
trenneLogStream();
zeigeAnmeldung();
});
// Bei unberechtigtem API-Aufruf (401) sofort zur Anmeldung springen
beiUnberechtigt(() => {
trenneStrom();
trenneLogStream();
zeigeAnmeldung();
});
// Verbindungsstatus des Ereignisstroms
beiVerbindungsStatus((istOffen) => {
if (!verbindungPunkt) return;
if (istOffen) {
verbindungPunkt.classList.add("offen");
verbindungPunkt.title = "Ereignisstrom: verbunden";
} else {
verbindungPunkt.classList.remove("offen");
verbindungPunkt.title = "Ereignisstrom: getrennt";
}
});
// ─── 3. Start & Initialisierung ───
initialisiereTheme();
initialisiereLogin(starteHauptAnwendung);
async function aktualisiereKopfStatus() {
try {
const status = await api("/status");
if (kopfOpenRouter && status) {
kopfOpenRouter.textContent = status.creditsText
? `OpenRouter: ${status.creditsText}`
: (status.isApiReachable ? "OpenRouter: OK" : "OpenRouter: offline");
}
} catch {
if (kopfOpenRouter) kopfOpenRouter.textContent = "";
}
}
async function aktualisiereStagingZaehler() {
const anzahl = await holeWartendeAnzahl();
const badge = document.getElementById("staging-badge");
if (badge) {
if (anzahl > 0) {
badge.textContent = String(anzahl);
badge.hidden = false;
} else {
badge.hidden = true;
}
}
}
async function starteHauptAnwendung(info) {
versteckeAnmeldung();
if (kopfInstanz && info) {
kopfInstanz.textContent = `${info.instanceName} (${info.instanceId})`;
}
// Ereignisstrom verbinden
verbindeStrom();
// Status & Zähler laden
await aktualisiereKopfStatus();
await aktualisiereStagingZaehler();
// Ereignisse für Kopf-Updates
abonnieren("status", () => {
aktualisiereKopfStatus();
});
abonnieren("instance", (daten) => {
if (daten.kind === "Settings") {
aktualisiereKopfStatus();
}
});
// Router initialisieren
initialisiereRouter();
}
// Prüfe bestehende Session
if (hatToken()) {
api("/info")
.then((info) => {
starteHauptAnwendung(info);
})
.catch(() => {
loescheToken();
zeigeAnmeldung();
});
} else {
zeigeAnmeldung();
}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ClawdDotNet</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<!-- ─── Anmelde-Ansicht ─── -->
<div id="anmeldung">
<div class="karte anmelde-kasten">
<div class="karte-kopf">
<h2>ClawdDotNet</h2>
</div>
<p class="hinweis">
Bitte gib das Zugangstoken der Instanz ein (<code>webUiToken</code> in der Instanzdatei).
</p>
<form id="anmeldeformular" class="formular-raster">
<input type="password" id="token-eingabe" placeholder="Zugangstoken (mind. 20 Zeichen)" autocomplete="current-password" required>
<button type="submit" class="primaer">Anmelden</button>
<p id="anmeldefehler" class="fehler" hidden></p>
</form>
</div>
</div>
<!-- ─── Hauptanwendung ─── -->
<div id="anwendung" hidden>
<div id="seitenleiste-backdrop" class="seitenleiste-backdrop" hidden></div>
<!-- Seitenleiste / Navigation -->
<aside id="seitenleiste" class="seitenleiste">
<div class="seitenleiste-kopf">
<span class="marke">ClawdDotNet</span>
<button id="sidebar-toggle" class="schaltflaeche-icon flach" title="Seitenleiste ein-/ausklappen" type="button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
</div>
<nav class="seitenleiste-inhalt">
<div class="nav-gruppe">
<div class="nav-gruppe-titel">Arbeiten</div>
<a href="#/chat" class="nav-link" title="Chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
<span class="nav-label">Chat</span>
</a>
<a href="#/agenten" class="nav-link" title="Agenten">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="nav-label">Agenten</span>
</a>
<a href="#/aufgaben" class="nav-link" title="Aufgaben">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span class="nav-label">Aufgaben</span>
</a>
<a href="#/dienste" class="nav-link" title="Dienste">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M4 14a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6z M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z M12 6v6 M8 8h.01 M16 8h.01"/>
</svg>
<span class="nav-label">Dienste</span>
</a>
</div>
<div class="nav-gruppe">
<div class="nav-gruppe-titel">Analyse</div>
<a href="#/verbrauch" class="nav-link" title="Tokennutzung">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7"/>
</svg>
<span class="nav-label">Tokennutzung</span>
</a>
<a href="#/agenten-chats" class="nav-link" title="Agenten-Chats">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M8 12h.01M12 12h.01M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z"/>
</svg>
<span class="nav-label">Agenten-Chats</span>
</a>
</div>
<div class="nav-gruppe">
<div class="nav-gruppe-titel">System</div>
<a href="#/staging" class="nav-link" title="Freigaben">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4"/>
</svg>
<span class="nav-label">Freigaben</span>
<span id="staging-badge" class="nav-badge" hidden>0</span>
</a>
<a href="#/protokoll" class="nav-link" title="Protokoll">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6"/>
</svg>
<span class="nav-label">Protokoll</span>
</a>
<a href="#/sicherung" class="nav-link" title="Sicherung">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8"/>
</svg>
<span class="nav-label">Sicherung</span>
</a>
<a href="#/einstellungen" class="nav-link" title="Einstellungen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="nav-label">Einstellungen</span>
</a>
<a href="#/info" class="nav-link" title="Info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
<span class="nav-label">Info</span>
</a>
</div>
</nav>
</aside>
<!-- Hauptinhalt -->
<div class="hauptbereich">
<header class="kopfzeile">
<div class="kopfzeile-links">
<button id="mobil-menue-btn" class="schaltflaeche-icon flach" title="Menü öffnen" type="button">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<span id="kopf-instanz" class="gedaempft"></span>
</div>
<div class="kopfzeile-rechts">
<span id="kopf-openrouter-status" class="gedaempft text-klein"></span>
<span id="verbindung-punkt" class="status-punkt" title="Ereignisstrom: getrennt"></span>
<button id="theme-schalter" class="schaltflaeche-icon flach" title="Farbschema umschalten" type="button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</button>
<button id="abmelden-btn" class="schaltflaeche flach klein" title="Abmelden" type="button">
Abmelden
</button>
</div>
</header>
<main class="inhalt-scroll">
<div id="ansicht-inhalt"></div>
</main>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
// ClawdDotNet — API-Zugriffsschicht
//
// Verwaltet das Zugangstoken im sessionStorage und kapselt fetch-Aufrufe.
export const TOKEN_SCHLUESSEL = "clawd.token";
let token = sessionStorage.getItem(TOKEN_SCHLUESSEL) ?? "";
const unberechtigtCallbacks = new Set();
/**
* Gibt das aktuell gespeicherte Token zurück.
* @returns {string}
*/
export function holeToken() {
return token;
}
/**
* Speichert ein neues Token im Speicher und sessionStorage.
* @param {string} neuesToken
*/
export function setzeToken(neuesToken) {
token = (neuesToken || "").trim();
if (token) {
sessionStorage.setItem(TOKEN_SCHLUESSEL, token);
} else {
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
}
}
/**
* Löscht das Token (Abmeldung).
*/
export function loescheToken() {
token = "";
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
}
/**
* Prüft, ob ein Token im Speicher liegt.
* @returns {boolean}
*/
export function hatToken() {
return Boolean(token && token.length > 0);
}
/**
* Registriert einen Rückruf für den Fall einer 401-Ablehnung (sofortiger Login-Sprung).
* @param {() => void} callback
*/
export function beiUnberechtigt(callback) {
unberechtigtCallbacks.add(callback);
return () => unberechtigtCallbacks.delete(callback);
}
function meldeUnberechtigt() {
loescheToken();
for (const cb of unberechtigtCallbacks) {
try {
cb();
} catch (err) {
console.error("Fehler im Unberechtigt-Callback:", err);
}
}
}
/**
* Führt einen Aufruf gegen die API aus.
* @param {string} pfad Pfad relativ zu /api, z.B. "/info" oder "/agents"
* @param {RequestInit} [optionen={}]
* @returns {Promise<any>}
*/
export async function api(pfad, optionen = {}) {
const kopfzeilen = {
...(optionen.headers ?? {}),
Authorization: `Bearer ${token}`
};
if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) {
kopfzeilen["Content-Type"] = "application/json";
optionen.body = JSON.stringify(optionen.body);
}
const antwort = await fetch(`/api${pfad}`, {
...optionen,
headers: kopfzeilen
});
if (antwort.status === 401) {
meldeUnberechtigt();
const fehler = new Error("Kein gültiges Token. Bitte neu anmelden.");
fehler.abgelehnt = true;
fehler.status = 401;
throw fehler;
}
if (!antwort.ok) {
let meldung = `HTTP ${antwort.status}`;
try {
const rumpf = await antwort.json();
meldung = rumpf?.message ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
} catch {
// Wenn kein JSON geliefert wird, bleibt es beim HTTP-Code
}
const fehler = new Error(meldung);
fehler.status = antwort.status;
throw fehler;
}
if (antwort.status === 204) {
return null;
}
return antwort.json();
}
+280
View File
@@ -0,0 +1,280 @@
// ClawdDotNet — Sichere DOM-Helferfunktionen
//
// Leitfaden-Regel 3.1: Fremdtext ist nie HTML. Kein innerHTML im gesamten Baum.
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* Erzeugt ein HTML-Element sicher ohne innerHTML.
* @param {string} tag
* @param {object} [eigenschaften]
* @param {...(Node|string|null|undefined)} kinder
* @returns {HTMLElement}
*/
export function erstelleElement(tag, eigenschaften = {}, ...kinder) {
const el = document.createElement(tag);
for (const [schluessel, wert] of Object.entries(eigenschaften)) {
if (wert === undefined || wert === null) continue;
if (schluessel === "klasse" || schluessel === "className") {
el.className = wert;
} else if (schluessel === "text") {
el.textContent = wert;
} else if (schluessel === "stil" || schluessel === "style") {
if (typeof wert === "string") {
el.style.cssText = wert;
} else {
Object.assign(el.style, wert);
}
} else if (schluessel.startsWith("on") && typeof wert === "function") {
const eventName = schluessel.slice(2).toLowerCase();
el.addEventListener(eventName, wert);
} else if (schluessel === "dataset" && typeof wert === "object") {
Object.assign(el.dataset, wert);
} else if (typeof wert === "boolean") {
if (wert) el.setAttribute(schluessel, "");
else el.removeAttribute(schluessel);
} else {
el.setAttribute(schluessel, String(wert));
}
}
for (const kind of kinder) {
if (kind === null || kind === undefined) continue;
if (typeof kind === "string" || typeof kind === "number") {
el.append(document.createTextNode(String(kind)));
} else if (kind instanceof Node) {
el.append(kind);
}
}
return el;
}
/**
* Erzeugt ein SVG-Icon sicher über den SVG-Namensraum ohne innerHTML.
* @param {string} pfadDaten SVG path d attribute
* @param {number} [groesse=16]
* @param {string} [klasse=""]
* @returns {SVGSVGElement}
*/
export function erstelleIcon(pfadDaten, groesse = 16, klasse = "") {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", String(groesse));
svg.setAttribute("height", String(groesse));
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.75");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
if (klasse) svg.setAttribute("class", `icon ${klasse}`);
else svg.setAttribute("class", "icon");
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", pfadDaten);
svg.appendChild(path);
return svg;
}
/**
* Häufig genutzte SVG-Pfade (Lucide-kompatibel, 24x24).
*/
export const PFADE = {
chat: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z",
agenten: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75",
aufgaben: "M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
dienste: "M4 14a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6z M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z M12 6v6 M8 8h.01 M16 8h.01",
verbrauch: "M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7",
staging: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4",
protokoll: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6",
sicherung: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8",
einstellungen: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M12 16v-4 M12 8h.01",
plus: "M12 5v14 M5 12h14",
muelleimer: "M3 6h18 M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M10 11v6 M14 11v6",
abspielen: "M5 3l14 9-14 9V3z",
abbrechen: "M18 6L6 18 M6 6l12 12",
senden: "M22 2L11 13 M22 2l-7 20-4-9-9-4 20-7z",
leeren: "M19 7l-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16",
aktualisieren: "M21.5 2v6h-6 M2.5 22v-6h6 M2 11.5a10 10 0 0 1 18.8-4.3L21.5 8 M22 12.5a10 10 0 0 1-18.8 4.2L2.5 16",
sonne: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
mond: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z",
menue: "M3 12h18 M3 6h18 M3 18h18",
schliessen: "M18 6L6 18 M6 6l12 12",
pfeilLinks: "M19 12H5 M12 19l-7-7 7-7",
pfeilRechts: "M5 12h14 M12 5l7 7-7 7",
pfeilUnten: "M6 9l6 6 6-6",
haken: "M20 6L9 17l-5-5",
ausrufezeichen: "M12 8v4 M12 16h.01 M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z",
ausloggen: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9"
};
/**
* Erzeugt eine Statusplakette (Badge).
* @param {string} text
* @param {"erfolg"|"warnung"|"gefahr"|"neutral"|"laufend"} art
* @returns {HTMLElement}
*/
export function erstellePlakette(text, art = "neutral") {
return erstelleElement("span", { klasse: `plakette plakette-${art}`, text });
}
/**
* Erstellt eine Karte mit optionalem Blueprint-Rahmen.
* @param {string} [titel]
* @param {HTMLElement[]} [elemente]
* @param {object} [optionen]
* @returns {HTMLElement}
*/
export function erstelleKarte(titel, elemente = [], optionen = {}) {
const card = erstelleElement("div", {
klasse: `karte ${optionen.klasse ?? ""}`.trim()
});
if (titel) {
const kopf = erstelleElement("div", { klasse: "karte-kopf" });
const h = erstelleElement("h2", { text: titel });
kopf.append(h);
if (optionen.aktionen) kopf.append(optionen.aktionen);
card.append(kopf);
}
for (const el of elemente) {
if (el) card.append(el);
}
return card;
}
/**
* Erzeugt einen standardisierten Modaldialog.
* @param {string} titel
* @param {HTMLElement} inhalt
* @param {HTMLElement[]} [aktionen]
* @returns {{ dialog: HTMLElement, oeffnen: () => void, schliessen: () => void }}
*/
export function erstelleModal(titel, inhalt, aktionen = []) {
const overlay = erstelleElement("div", { klasse: "modal-overlay", hidden: true });
const box = erstelleElement("div", { klasse: "modal-box karte" });
const kopf = erstelleElement("div", { klasse: "modal-kopf" });
const h = erstelleElement("h2", { text: titel });
const schliessenBtn = erstelleElement(
"button",
{ klasse: "schaltflaeche-icon flach", title: "Schließen", type: "button" },
erstelleIcon(PFADE.schliessen, 16)
);
kopf.append(h, schliessenBtn);
const koerper = erstelleElement("div", { klasse: "modal-koerper" }, inhalt);
const fuss = erstelleElement("div", { klasse: "modal-fuss" });
for (const aktion of aktionen) fuss.append(aktion);
box.append(kopf, koerper, fuss);
overlay.append(box);
document.body.append(overlay);
const schliessen = () => {
overlay.hidden = true;
overlay.classList.remove("sichtbar");
document.removeEventListener("keydown", tasteHandler);
};
const oeffnen = () => {
overlay.hidden = false;
overlay.classList.add("sichtbar");
document.addEventListener("keydown", tasteHandler);
const erstesInput = box.querySelector("input, select, textarea, button:not(.schaltflaeche-icon)");
erstesInput?.focus();
};
const tasteHandler = (e) => {
if (e.key === "Escape") schliessen();
};
schliessenBtn.addEventListener("click", schliessen);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) schliessen();
});
return { dialog: overlay, oeffnen, schliessen };
}
/**
* Formatiert Zeitangaben (z.B. "14:32:05").
* @param {string|Date|number|null} wert
* @returns {string}
*/
export function formatiereZeit(wert) {
if (!wert) return "—";
const d = wert instanceof Date ? wert : new Date(wert);
if (isNaN(d.getTime())) return String(wert);
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
/**
* Formatiert Datum und Uhrzeit.
* @param {string|Date|number|null} wert
* @returns {string}
*/
export function formatiereDatumZeit(wert) {
if (!wert) return "—";
const d = wert instanceof Date ? wert : new Date(wert);
if (isNaN(d.getTime())) return String(wert);
return d.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
/**
* Formatiert Tokenzahlen mit Tausendertrennzeichen.
* @param {number|null} zahl
* @returns {string}
*/
export function formatiereTokens(zahl) {
if (zahl === null || zahl === undefined) return "0";
return new Intl.NumberFormat("de-DE").format(zahl);
}
/**
* Formatiert Kosten (USD / EUR).
* @param {number|null} kosten
* @returns {string}
*/
export function formatiereKosten(kosten) {
if (kosten === null || kosten === undefined) return "$0.00";
return "$" + Number(kosten).toFixed(4);
}
/**
* Zeigt ein kurzes Benachrichtigungsbanner oben im Fenster.
* @param {string} text
* @param {"info"|"erfolg"|"warnung"|"gefahr"} [art="info"]
* @param {number} [dauerMs=4000]
*/
export function zeigeMeldung(text, art = "info", dauerMs = 4000) {
let container = document.getElementById("toast-container");
if (!container) {
container = erstelleElement("div", { id: "toast-container", klasse: "toast-container" });
document.body.append(container);
}
const toast = erstelleElement("div", { klasse: `toast toast-${art}` });
const iconArt = art === "erfolg" ? PFADE.haken : art === "warnung" || art === "gefahr" ? PFADE.ausrufezeichen : PFADE.info;
toast.append(erstelleIcon(iconArt, 16), erstelleElement("span", { text }));
container.append(toast);
setTimeout(() => {
toast.classList.add("ausblenden");
setTimeout(() => toast.remove(), 300);
}, dauerMs);
}
@@ -0,0 +1,119 @@
// ClawdDotNet — Ereignisstrom (Server-Sent Events)
//
// Hört auf /events und verteilt anwendungsintern Ereignisse (chat, step, run, instance, status).
import { holeToken } from "./api.js";
let strom = null;
const abonnenten = new Map();
const verbindungsStatusCallbacks = new Set();
let verbunden = false;
/**
* Registriert einen Rückruf für Änderungen des Verbindungsstatus (offen / getrennt).
* @param {(status: boolean) => void} callback
*/
export function beiVerbindungsStatus(callback) {
verbindungsStatusCallbacks.add(callback);
callback(verbunden);
return () => verbindungsStatusCallbacks.delete(callback);
}
function meldeStatus(istOffen) {
if (verbunden === istOffen) return;
verbunden = istOffen;
for (const cb of verbindungsStatusCallbacks) {
try {
cb(verbunden);
} catch (err) {
console.error("Fehler im Verbindungsstatus-Callback:", err);
}
}
}
/**
* Abonniert ein bestimmtes Server-Ereignis.
* @param {string} ereignis Name des Ereignisses ("chat", "step", "run", "instance", "status")
* @param {(daten: any) => void} callback
* @returns {() => void} Abmeldefunktion
*/
export function abonnieren(ereignis, callback) {
if (!abonnenten.has(ereignis)) {
abonnenten.set(ereignis, new Set());
}
abonnenten.get(ereignis).add(callback);
return () => {
const s = abonnenten.get(ereignis);
if (s) {
s.delete(callback);
if (s.size === 0) abonnenten.delete(ereignis);
}
};
}
function verteileEreignis(ereignis, rohdaten) {
const gruppe = abonnenten.get(ereignis);
if (!gruppe || gruppe.size === 0) return;
let daten = rohdaten;
try {
daten = JSON.parse(rohdaten);
} catch {
// Falls Rohdaten kein JSON waren, unverändert übergeben
}
for (const cb of gruppe) {
try {
cb(daten);
} catch (err) {
console.error(`Fehler bei Verarbeitung von ${ereignis}:`, err);
}
}
}
/**
* Startet den Ereignisstrom, falls ein Token vorliegt.
*/
export function verbindeStrom() {
trenneStrom();
const token = holeToken();
if (!token) return;
// EventSource kann keine Header setzen: Token wird über die Query übergeben (siehe TokenGate.cs)
strom = new EventSource(`/events?token=${encodeURIComponent(token)}`);
strom.onopen = () => {
meldeStatus(true);
};
// EventSource verbindet automatisch wieder bei Abbruch.
strom.onerror = () => {
meldeStatus(false);
};
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
for (const art of bekannteEreignisse) {
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
}
}
/**
* Schließt die bestehende Verbindung zum Ereignisstrom.
*/
export function trenneStrom() {
if (strom) {
strom.close();
strom = null;
}
meldeStatus(false);
}
/**
* Gibt an, ob der Ereignisstrom aktuell aktiv verbunden ist.
* @returns {boolean}
*/
export function istVerbunden() {
return verbunden;
}
@@ -0,0 +1,123 @@
// ClawdDotNet — Hash-basierter Router
//
// Leitfaden-Regel 3.2: Navigation über #. Kein history.pushState, keine Serverrouten.
const routen = new Map();
let aktiverPfad = "";
let navigationsListener = new Set();
/**
* Registriert einen Handler für eine Route.
* @param {string} muster z.B. "/chat", "/chat/:agentId", "/agenten"
* @param {(parameter: object) => Promise<void>|void} handler
*/
export function registriereRoute(muster, handler) {
routen.set(muster, handler);
}
/**
* Registriert einen allgemeinen Listener, der bei jedem Routenwechsel aufgerufen wird.
* @param {(pfad: string, parameter: object) => void} listener
*/
export function beiRoutenwechsel(listener) {
navigationsListener.add(listener);
return () => navigationsListener.delete(listener);
}
/**
* Navigiert zu einem bestimmten Hash.
* @param {string} hash z.B. "#/agenten" oder "/agenten"
*/
export function navigiere(hash) {
const bereinigt = hash.startsWith("#") ? hash : `#${hash}`;
if (window.location.hash !== bereinigt) {
window.location.hash = bereinigt;
} else {
fuehreRouteAus();
}
}
function parseHash() {
const hash = window.location.hash || "#/chat";
const pfad = hash.startsWith("#") ? hash.slice(1) : hash;
return pfad.startsWith("/") ? pfad : `/${pfad}`;
}
function findePassendeRoute(pfad) {
const pfadTeile = pfad.split("/").filter(Boolean);
for (const [muster, handler] of routen.entries()) {
const musterTeile = muster.split("/").filter(Boolean);
if (pfadTeile.length !== musterTeile.length) continue;
const params = {};
let passt = true;
for (let i = 0; i < musterTeile.length; i++) {
if (musterTeile[i].startsWith(":")) {
const paramName = musterTeile[i].slice(1);
params[paramName] = decodeURIComponent(pfadTeile[i]);
} else if (musterTeile[i] !== pfadTeile[i]) {
passt = false;
break;
}
}
if (passt) {
return { handler, params, muster };
}
}
// Fallback: Erste Route oder 404
return null;
}
async function fuehreRouteAus() {
const pfad = parseHash();
aktiverPfad = pfad;
aktualisiereAktivenNavEintrag(pfad);
const match = findePassendeRoute(pfad);
if (match) {
for (const listener of navigationsListener) {
listener(pfad, match.params);
}
await match.handler(match.params);
} else if (routen.has("/chat")) {
navigiere("#/chat");
}
}
function aktualisiereAktivenNavEintrag(pfad) {
const basisPfad = "/" + pfad.split("/").filter(Boolean)[0];
const navLinks = document.querySelectorAll(".nav-link[href]");
for (const link of navLinks) {
const href = link.getAttribute("href") || "";
const zielPfad = href.startsWith("#") ? href.slice(1) : href;
const zielBasis = "/" + zielPfad.split("/").filter(Boolean)[0];
if (basisPfad === zielBasis) {
link.classList.add("aktiv");
} else {
link.classList.remove("aktiv");
}
}
}
/**
* Initialisiert den Router und startet die Überwachung.
*/
export function initialisiereRouter() {
window.addEventListener("hashchange", fuehreRouteAus);
fuehreRouteAus();
}
/**
* Gibt den aktuellen Pfad zurück.
* @returns {string}
*/
export function holeAktuellenPfad() {
return aktiverPfad;
}
@@ -0,0 +1,54 @@
// ClawdDotNet — Theme-Verwaltung (Hell / Dunkel)
//
// Farbschema nach Theme.axaml / Mockup. Unterstützt system, hell, dunkel.
const THEME_SCHLUESSEL = "clawd.theme";
let aktuellerModus = localStorage.getItem(THEME_SCHLUESSEL) || "system";
const systemQuery = window.matchMedia("(prefers-color-scheme: dark)");
function wendeThemeAn() {
const istDunkel =
aktuellerModus === "dunkel" ||
(aktuellerModus === "system" && systemQuery.matches);
if (istDunkel) {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.setAttribute("data-theme", "light");
}
}
systemQuery.addEventListener("change", () => {
if (aktuellerModus === "system") {
wendeThemeAn();
}
});
/**
* Initialisiert das Theme beim Start.
*/
export function initialisiereTheme() {
wendeThemeAn();
}
/**
* Schaltet zwischen Hell und Dunkel um.
* @returns {string} Neues aktives Theme
*/
export function wechsleTheme() {
const istJetztDunkel = document.documentElement.getAttribute("data-theme") === "dark";
aktuellerModus = istJetztDunkel ? "hell" : "dunkel";
localStorage.setItem(THEME_SCHLUESSEL, aktuellerModus);
wendeThemeAn();
return aktuellerModus;
}
/**
* Gibt das aktuell gewählte Theme zurück.
* @returns {"system"|"hell"|"dunkel"}
*/
export function holeAktuellesTheme() {
return aktuellerModus;
}
+413
View File
@@ -0,0 +1,413 @@
// ClawdDotNet — Agenten-Verwaltung
//
// Übersicht, Anlegen, Löschen von Agenten, Werkzeug-Schalter und maskierte Einstellungen.
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
zeigeMeldung
} from "../modules/dom.js";
let gewaehlterAgentId = null;
let agenten = [];
let entladeEreignisse = [];
export async function rendereAgenten(parameter = {}) {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "agenten-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Agenten" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerAgentDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Agent" })
)
);
const raster = erstelleElement("div", { klasse: "agenten-raster" });
const listeContainer = erstelleElement("div", { klasse: "agenten-liste-bereich" });
const detailContainer = erstelleElement("div", { klasse: "agenten-detail-bereich" });
raster.append(listeContainer, detailContainer);
layout.append(kopfZeile, raster);
container.append(layout);
await ladeUndRendereListe(parameter.agentId);
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Agents") {
ladeUndRendereListe(gewaehlterAgentId);
}
})
);
}
async function ladeUndRendereListe(zielId = null) {
const listeContainer = document.querySelector(".agenten-liste-bereich");
if (!listeContainer) return;
listeContainer.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Agenten…" })
);
try {
agenten = await api("/agents");
listeContainer.replaceChildren();
if (!agenten || agenten.length === 0) {
listeContainer.append(
erstelleElement("div", {
klasse: "hinweis-zentriert",
text: "Keine Agenten eingerichtet."
})
);
rendereDetails(null);
return;
}
const auswahlId = zielId && agenten.some((a) => a.agentId === zielId)
? zielId
: agenten[0].agentId;
for (const a of agenten) {
const eintrag = erstelleElement("div", {
klasse: `agenten-karte ${a.agentId === auswahlId ? "aktiv" : ""}`,
onclick: () => {
document.querySelectorAll(".agenten-karte").forEach((k) => k.classList.remove("aktiv"));
eintrag.classList.add("aktiv");
rendereDetails(a.agentId);
}
});
const top = erstelleElement("div", { klasse: "agent-karte-top" });
const name = erstelleElement("strong", { text: a.displayName });
const status = a.isRunning
? erstellePlakette("Beschäftigt", "warnung")
: erstellePlakette("Bereit", "neutral");
top.append(name, status);
const meta = erstelleElement("div", { klasse: "agent-karte-meta" });
meta.append(
erstelleElement("span", { klasse: "gedaempft", text: `ID: ${a.agentId}` }),
erstelleElement("span", { klasse: "gedaempft", text: ` · Modell: ${a.model}` })
);
const werkzeugeZaehler = a.tools ? a.tools.filter((t) => t.enabled).length : 0;
const werkzeugeText = erstelleElement("div", {
klasse: "agent-karte-werkzeuge gedaempft",
text: `${werkzeugeZaehler} Werkzeuge aktiv`
});
eintrag.append(top, meta, werkzeugeText);
listeContainer.append(eintrag);
}
rendereDetails(auswahlId);
} catch (err) {
listeContainer.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function rendereDetails(agentId) {
gewaehlterAgentId = agentId;
const detailContainer = document.querySelector(".agenten-detail-bereich");
if (!detailContainer) return;
detailContainer.replaceChildren();
if (!agentId) {
detailContainer.append(
erstelleKarte("Agent-Details", [
erstelleElement("p", { klasse: "gedaempft", text: "Wähle links einen Agenten aus." })
])
);
return;
}
const agent = agenten.find((a) => a.agentId === agentId);
if (!agent) return;
// Kopf-Aktionen (Chatten, Löschen)
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const zumChatBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Mit diesem Agenten chatten",
onclick: () => {
window.location.hash = `#/chat/${encodeURIComponent(agent.agentId)}`;
}
},
erstelleIcon(PFADE.chat, 14),
erstelleElement("span", { text: " Chatten" })
);
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach",
title: "Diesen Agenten löschen",
onclick: () => loescheAgent(agent.agentId, agent.displayName)
},
erstelleIcon(PFADE.muelleimer, 14),
erstelleElement("span", { text: " Löschen" })
);
aktionen.append(zumChatBtn, loeschenBtn);
// Stammdaten-Karte
const stammdatenListe = erstelleElement("dl", { klasse: "datenliste" });
const felder = [
["Name", agent.displayName],
["Kennung (ID)", agent.agentId],
["Modell", agent.model],
["Beschreibung", agent.description || "—"]
];
for (const [bez, wert] of felder) {
stammdatenListe.append(
erstelleElement("dt", { text: bez }),
erstelleElement("dd", { text: wert })
);
}
const basisKarte = erstelleKarte(agent.displayName, [stammdatenListe], { aktionen });
// Werkzeuge-Karte
const werkzeugeListe = erstelleElement("div", { klasse: "werkzeuge-liste" });
if (!agent.tools || agent.tools.length === 0) {
werkzeugeListe.append(
erstelleElement("p", { klasse: "gedaempft", text: "Keine Werkzeuge für diesen Agenten konfiguriert." })
);
} else {
for (const tool of agent.tools) {
const zeile = erstelleElement("div", { klasse: "werkzeug-zeile" });
const links = erstelleElement("div", { klasse: "werkzeug-info" });
const toolTitel = erstelleElement("strong", { text: tool.toolName });
const toolDesc = erstelleElement("div", {
klasse: "werkzeug-beschreibung gedaempft",
text: tool.description || ""
});
links.append(toolTitel, toolDesc);
const rechts = erstelleElement("div", { klasse: "werkzeug-aktionen" });
const einstellungenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
title: "Werkzeug-Einstellungen einsehen",
onclick: () => zeigeWerkzeugEinstellungen(agent.agentId, tool.toolName)
},
erstelleIcon(PFADE.einstellungen, 14),
erstelleElement("span", { text: " Konfiguration" })
);
const toggleLabel = erstelleElement("label", { klasse: "schalter" });
const toggleInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(tool.enabled),
onchange: async (e) => {
const aktiv = e.target.checked;
try {
await api(
`/agents/${encodeURIComponent(agent.agentId)}/tools/${encodeURIComponent(tool.toolName)}?enabled=${aktiv}`,
{ method: "PUT" }
);
tool.enabled = aktiv;
zeigeMeldung(`Werkzeug '${tool.toolName}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
} catch (err) {
e.target.checked = !aktiv;
zeigeMeldung(`Änderung fehlgeschlagen: ${err.message}`, "gefahr");
}
}
});
const toggleSpan = erstelleElement("span", { klasse: "schalter-regler" });
toggleLabel.append(toggleInput, toggleSpan);
rechts.append(einstellungenBtn, toggleLabel);
zeile.append(links, rechts);
werkzeugeListe.append(zeile);
}
}
const werkzeugeKarte = erstelleKarte("Verfügbare Werkzeuge", [werkzeugeListe]);
detailContainer.append(basisKarte, werkzeugeKarte);
}
function oeffneNeuerAgentDialog() {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const idFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. scout",
id: "neu-agent-id"
});
const nameFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. Scout Agent",
id: "neu-agent-name"
});
const modellFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. anthropic/claude-3.5-sonnet",
value: "anthropic/claude-3.5-sonnet",
id: "neu-agent-modell"
});
const descFeld = erstelleElement("textarea", {
rows: "3",
placeholder: "Optionale Beschreibung des Agenten…",
id: "neu-agent-desc"
});
formular.append(
erstelleElement("label", { text: "Agent-ID (eindeutiger Schlüssel):" }),
idFeld,
erstelleElement("label", { text: "Anzeigename:" }),
nameFeld,
erstelleElement("label", { text: "Modell (OpenRouter):" }),
modellFeld,
erstelleElement("label", { text: "Beschreibung:" }),
descFeld
);
const abbrechenBtn = erstelleElement(
"button",
{
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen()
},
erstelleElement("span", { text: "Abbrechen" })
);
const speichernBtn = erstelleElement(
"button",
{
type: "submit",
klasse: "schaltflaeche primaer"
},
erstelleElement("span", { text: "Agent anlegen" })
);
const modal = erstelleModal("Neuen Agenten anlegen", formular, [abbrechenBtn, speichernBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
speichernBtn.disabled = true;
try {
await api("/agents", {
method: "POST",
body: {
agentId: idFeld.value.trim(),
displayName: nameFeld.value.trim(),
model: modellFeld.value.trim(),
description: descFeld.value.trim()
}
});
modal.schliessen();
zeigeMeldung(`Agent '${nameFeld.value.trim()}' wurde erfolgreich angelegt.`, "erfolg");
await ladeUndRendereListe(idFeld.value.trim());
} catch (err) {
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
} finally {
speichernBtn.disabled = false;
}
});
modal.oeffnen();
}
async function loescheAgent(agentId, name) {
if (!confirm(`Soll der Agent '${name}' (${agentId}) wirklich gelöscht werden?`)) return;
try {
await api(`/agents/${encodeURIComponent(agentId)}`, { method: "DELETE" });
zeigeMeldung(`Agent '${name}' wurde gelöscht.`, "info");
await ladeUndRendereListe();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function zeigeWerkzeugEinstellungen(agentId, toolName) {
const box = erstelleElement("div", { klasse: "einstellungen-box" });
box.append(erstelleElement("p", { klasse: "gedaempft", text: "Lade Konfiguration…" }));
const schliessenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Schließen"
});
const modal = erstelleModal(
`Einstellungen: ${toolName} (${agentId})`,
box,
[schliessenBtn]
);
modal.oeffnen();
try {
const daten = await api(
`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/settings`
);
box.replaceChildren();
const hinweis = erstelleElement("p", {
klasse: "hinweis",
text: "Geheime Werte (Passwörter, Schlüssel) werden vom Server maskiert ausgegeben."
});
box.append(hinweis);
if (!daten || Object.keys(daten).length === 0) {
box.append(
erstelleElement("p", { klasse: "gedaempft", text: "Keine individuellen Einstellungen hinterlegt." })
);
return;
}
const dl = erstelleElement("dl", { klasse: "datenliste" });
for (const [schluessel, wert] of Object.entries(daten)) {
const dt = erstelleElement("dt", { text: schluessel });
const dd = erstelleElement("dd", { text: typeof wert === "object" ? JSON.stringify(wert) : String(wert) });
dl.append(dt, dd);
}
box.append(dl);
} catch (err) {
box.replaceChildren(
erstelleElement("p", { klasse: "fehler", text: `Laden fehlgeschlagen: ${err.message}` })
);
}
}
+394
View File
@@ -0,0 +1,394 @@
// ClawdDotNet — Chat-Ansicht
//
// Leitfaden: Sicherer Chatverlauf (strikt textContent), Echtzeit-Ereignisse über SSE.
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
formatiereZeit,
erstellePlakette,
zeigeMeldung
} from "../modules/dom.js";
let aktiverAgentId = null;
let agentenListe = [];
let nachrichtenContainer = null;
let schrittBox = null;
let eingabeTextarea = null;
let sendenButton = null;
let abbrechenButton = null;
let agentenSelector = null;
let statusPlakette = null;
let entladeEreignisse = [];
export async function rendereChat(parameter = {}) {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
// Alte Ereignisse aufräumen
entladeEreignisse.forEach((cleanup) => cleanup());
entladeEreignisse = [];
const chatShell = erstelleElement("div", { klasse: "chat-ansicht" });
// 1. Kopfleiste der Chat-Ansicht
const kopfleiste = erstelleElement("div", { klasse: "chat-kopf" });
const agentenWahl = erstelleElement("div", { klasse: "chat-agenten-wahl" });
agentenSelector = erstelleElement("select", {
klasse: "eingabe-select",
onchange: (e) => waehleAgent(e.target.value)
});
statusPlakette = erstellePlakette("Lädt…", "neutral");
agentenWahl.append(erstelleElement("label", { text: "Agent: " }), agentenSelector, statusPlakette);
const aktionen = erstelleElement("div", { klasse: "chat-aktionen" });
const weckenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Agenten manuell ausführen / wecken",
onclick: weckeAktivenAgent
},
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Wecken" })
);
const leerenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Chatverlauf dieses Agenten leeren",
onclick: leereChatverlauf
},
erstelleIcon(PFADE.leeren, 14),
erstelleElement("span", { text: " Verlauf leeren" })
);
abbrechenButton = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach",
title: "Laufenden Lauf abbrechen",
hidden: true,
onclick: brecheChatAb
},
erstelleIcon(PFADE.abbrechen, 14),
erstelleElement("span", { text: " Abbrechen" })
);
aktionen.append(weckenBtn, leerenBtn, abbrechenButton);
kopfleiste.append(agentenWahl, aktionen);
// 2. Nachrichtenverlauf & Schrittanzeige
nachrichtenContainer = erstelleElement("div", { klasse: "chat-verlauf" });
schrittBox = erstelleElement("div", { klasse: "chat-schritt-box", hidden: true });
const schrittIcon = erstelleIcon(PFADE.aktualisieren, 14, "rotierend");
const schrittText = erstelleElement("span", { klasse: "schritt-text", text: "" });
schrittBox.append(schrittIcon, schrittText);
// 3. Eingabebereich
const eingabeBereich = erstelleElement("div", { klasse: "chat-eingabe-bereich" });
eingabeTextarea = erstelleElement("textarea", {
klasse: "chat-eingabe",
placeholder: "Nachricht an den Agenten eingeben (Enter zum Senden, Umschalt+Enter für neue Zeile)…",
rows: "3",
onkeydown: (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendeNachricht();
}
}
});
sendenButton = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
title: "Nachricht senden",
onclick: sendeNachricht
},
erstelleIcon(PFADE.senden, 16),
erstelleElement("span", { text: "Senden" })
);
eingabeBereich.append(eingabeTextarea, sendenButton);
chatShell.append(kopfleiste, nachrichtenContainer, schrittBox, eingabeBereich);
container.append(chatShell);
// Lade Agenten und initialisiere
await ladeAgenten(parameter.agentId);
// SSE-Abonnements
entladeEreignisse.push(
abonnieren("chat", (daten) => {
if (daten.agentId === aktiverAgentId) {
fuegeNachrichtEin(daten);
}
})
);
entladeEreignisse.push(
abonnieren("step", (daten) => {
if (daten.agentId === aktiverAgentId) {
zeigeSchritt(daten.text);
}
})
);
entladeEreignisse.push(
abonnieren("run", (daten) => {
versteckeSchritt();
aktualisiereLaufStatus(false);
})
);
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Agents") {
ladeAgenten(aktiverAgentId);
}
})
);
}
async function ladeAgenten(gewuenschteId = null) {
try {
agentenListe = await api("/agents");
agentenSelector.replaceChildren();
if (!agentenListe || agentenListe.length === 0) {
const opt = erstelleElement("option", { text: "Keine Agenten vorhanden", value: "" });
agentenSelector.append(opt);
statusPlakette.replaceWith(erstellePlakette("Keine Agenten", "warnung"));
return;
}
for (const a of agentenListe) {
const opt = erstelleElement("option", {
value: a.agentId,
text: `${a.displayName} (${a.agentId})`
});
agentenSelector.append(opt);
}
const zielId = gewuenschteId && agentenListe.some((a) => a.agentId === gewuenschteId)
? gewuenschteId
: agentenListe[0].agentId;
agentenSelector.value = zielId;
await waehleAgent(zielId);
} catch (err) {
zeigeMeldung(`Fehler beim Laden der Agenten: ${err.message}`, "gefahr");
}
}
async function waehleAgent(agentId) {
aktiverAgentId = agentId;
const agent = agentenListe.find((a) => a.agentId === agentId);
aktualisiereLaufStatus(agent?.isRunning ?? false);
window.location.hash = `#/chat/${encodeURIComponent(agentId)}`;
await ladeChatVerlauf(agentId);
}
function aktualisiereLaufStatus(laeuft) {
if (statusPlakette) {
const neuePlakette = laeuft
? erstellePlakette("Beschäftigt", "warnung")
: erstellePlakette("Bereit", "erfolg");
statusPlakette.replaceWith(neuePlakette);
statusPlakette = neuePlakette;
}
if (abbrechenButton) {
abbrechenButton.hidden = !laeuft;
}
}
async function ladeChatVerlauf(agentId) {
if (!nachrichtenContainer) return;
nachrichtenContainer.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Verlauf…" })
);
try {
const verlauf = await api(`/agents/${encodeURIComponent(agentId)}/chat`);
nachrichtenContainer.replaceChildren();
if (!verlauf || verlauf.length === 0) {
nachrichtenContainer.append(
erstelleElement("div", {
klasse: "hinweis-zentriert",
text: "Noch keine Nachrichten im Verlauf vorhanden."
})
);
return;
}
for (const eintrag of verlauf) {
fuegeNachrichtEin(eintrag);
}
scrolleNachUnten();
} catch (err) {
nachrichtenContainer.replaceChildren(
erstelleElement("div", {
klasse: "fehler-zentriert",
text: `Verlauf konnte nicht geladen werden: ${err.message}`
})
);
}
}
function fuegeNachrichtEin(eintrag) {
// Falls aktuell der Hinweis angezeigt wird, entfernen
const platzhalter = nachrichtenContainer.querySelector(".hinweis-zentriert");
if (platzhalter) platzhalter.remove();
const rolle = (eintrag.role || "user").toLowerCase();
const istUser = rolle === "user";
const istSystem = rolle === "system";
const istTool = rolle === "tool";
let rollenKlasse = "nachricht-benutzer";
let rollenTitel = "Benutzer";
if (istSystem) {
rollenKlasse = "nachricht-system";
rollenTitel = "System";
} else if (istTool) {
rollenKlasse = "nachricht-werkzeug";
rollenTitel = "Werkzeug";
} else if (!istUser) {
rollenKlasse = "nachricht-agent";
rollenTitel = agentenListe.find((a) => a.agentId === aktiverAgentId)?.displayName || "Agent";
}
const zeile = erstelleElement("div", { klasse: `chat-zeile ${istUser ? "zeile-benutzer" : "zeile-fremd"}` });
const nachrichtCard = erstelleElement("div", { klasse: `nachricht-blase ${rollenKlasse}` });
const kopf = erstelleElement("div", { klasse: "nachricht-meta" });
const autor = erstelleElement("strong", { text: rollenTitel });
const zeit = erstelleElement("span", {
klasse: "nachricht-zeit",
text: formatiereZeit(eintrag.timestamp)
});
kopf.append(autor);
if (eintrag.source) {
const quelleBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.source });
kopf.append(quelleBadge);
}
kopf.append(zeit);
// WICHTIG (Regel 3.1): Fremdtext ist NIE HTML! Strikt textContent!
const textKörper = erstelleElement("div", {
klasse: "nachricht-text",
text: eintrag.content || ""
});
nachrichtCard.append(kopf, textKörper);
zeile.append(nachrichtCard);
nachrichtenContainer.append(zeile);
scrolleNachUnten();
}
function zeigeSchritt(text) {
if (!schrittBox) return;
const span = schrittBox.querySelector(".schritt-text");
if (span) span.textContent = text;
schrittBox.hidden = false;
aktualisiereLaufStatus(true);
}
function versteckeSchritt() {
if (schrittBox) schrittBox.hidden = true;
}
function scrolleNachUnten() {
if (nachrichtenContainer) {
nachrichtenContainer.scrollTop = nachrichtenContainer.scrollHeight;
}
}
async function sendeNachricht() {
if (!aktiverAgentId || !eingabeTextarea) return;
const nachricht = eingabeTextarea.value.trim();
if (!nachricht) return;
eingabeTextarea.value = "";
sendenButton.disabled = true;
aktualisiereLaufStatus(true);
try {
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat`, {
method: "POST",
body: { message: nachricht }
});
} catch (err) {
zeigeMeldung(`Nachricht konnte nicht gesendet werden: ${err.message}`, "gefahr");
aktualisiereLaufStatus(false);
} finally {
sendenButton.disabled = false;
eingabeTextarea.focus();
}
}
async function brecheChatAb() {
if (!aktiverAgentId) return;
try {
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat/abort`, {
method: "POST"
});
versteckeSchritt();
aktualisiereLaufStatus(false);
zeigeMeldung("Lauf wurde abgebrochen.", "warnung");
} catch (err) {
zeigeMeldung(`Abbruch fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function weckeAktivenAgent() {
if (!aktiverAgentId) return;
try {
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/run`, {
method: "POST"
});
aktualisiereLaufStatus(true);
zeigeMeldung("Agent wurde manuell gestartet.", "info");
} catch (err) {
zeigeMeldung(`Starten fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function leereChatverlauf() {
if (!aktiverAgentId) return;
if (!confirm("Soll der gesamte Chatverlauf dieses Agenten unwiderruflich gelöscht werden?")) return;
try {
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat`, {
method: "DELETE"
});
nachrichtenContainer.replaceChildren(
erstelleElement("div", {
klasse: "hinweis-zentriert",
text: "Verlauf wurde geleert."
})
);
zeigeMeldung("Chatverlauf geleert.", "info");
} catch (err) {
zeigeMeldung(`Leeren fehlgeschlagen: ${err.message}`, "gefahr");
}
}
+90
View File
@@ -0,0 +1,90 @@
// ClawdDotNet — System-Info & Status
//
// Zeigt Anwendungsversion, Instanzkennung, Teilsysteme und OpenRouter-Guthaben an.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
erstellePlakette
} from "../modules/dom.js";
export async function rendereInfo() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "info-ansicht" });
layout.append(erstelleElement("h1", { text: "System & Status" }));
const gitter = erstelleElement("div", { klasse: "info-gitter" });
layout.append(gitter);
container.append(layout);
gitter.append(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Systemdaten…" })
);
try {
const [info, status] = await Promise.all([
api("/info").catch(() => null),
api("/status").catch(() => null)
]);
gitter.replaceChildren();
// 1. Instanz & Version
if (info) {
const dlInstanz = erstelleElement("dl", { klasse: "datenliste" });
dlInstanz.append(
erstelleElement("dt", { text: "Instanzname" }),
erstelleElement("dd", {}, erstelleElement("strong", { text: info.instanceName })),
erstelleElement("dt", { text: "Instanz-ID" }),
erstelleElement("dd", {}, erstelleElement("code", { text: info.instanceId })),
erstelleElement("dt", { text: "Version" }),
erstelleElement("dd", { text: info.appVersion }),
erstelleElement("dt", { text: "Build" }),
erstelleElement("dd", { text: info.buildSummary })
);
gitter.append(erstelleKarte("Instanz & Programm", [dlInstanz]));
// 2. Teilsysteme
const dlSysteme = erstelleElement("dl", { klasse: "datenliste" });
dlSysteme.append(
erstelleElement("dt", { text: "Agent Engine" }),
erstelleElement("dd", {}, erstellePlakette(info.engineActive ? "Aktiv" : "Aus", info.engineActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Scanner / Scheduler" }),
erstelleElement("dd", {}, erstellePlakette(info.schedulerActive ? "Aktiv" : "Aus", info.schedulerActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Freigabestelle (Staging)" }),
erstelleElement("dd", {}, erstellePlakette(info.stagingActive ? "Aktiv" : "Aus", info.stagingActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Deploymentcenter" }),
erstelleElement("dd", {}, erstellePlakette(info.deploymentcenterActive ? "Verbunden" : "Nicht konfiguriert", info.deploymentcenterActive ? "erfolg" : "neutral"))
);
gitter.append(erstelleKarte("Dienste & Teilsysteme", [dlSysteme]));
}
// 3. OpenRouter / API-Status
const dlStatus = erstelleElement("dl", { klasse: "datenliste" });
if (status) {
dlStatus.append(
erstelleElement("dt", { text: "API-Erreichbarkeit" }),
erstelleElement("dd", {}, erstellePlakette(status.isApiReachable ? "Verbunden" : "Getrennt", status.isApiReachable ? "erfolg" : "gefahr")),
erstelleElement("dt", { text: "Statusmeldung" }),
erstelleElement("dd", { text: status.statusText || "—" }),
erstelleElement("dt", { text: "Guthaben / Credits" }),
erstelleElement("dd", {}, erstelleElement("strong", { text: status.creditsText || "—" }))
);
} else {
dlStatus.append(
erstelleElement("dt", { text: "OpenRouter" }),
erstelleElement("dd", { klasse: "gedaempft", text: "Kein API-Schlüssel oder Dienst nicht erreichbar." })
);
}
gitter.append(erstelleKarte("Modellanbindung (OpenRouter)", [dlStatus]));
} catch (err) {
gitter.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
@@ -0,0 +1,71 @@
// ClawdDotNet — Anmeldeseite
//
// Leitfaden: Ohne Token soll das UI sofort auf die Login- bzw. Tokeneingabe springen.
import { setzeToken, loescheToken, api } from "../modules/api.js";
import { erstelleElement, PFADE, erstelleIcon } from "../modules/dom.js";
let loginCallback = null;
export function initialisiereLogin(onErfolg) {
loginCallback = onErfolg;
const formular = document.getElementById("anmeldeformular");
const fehlerBox = document.getElementById("anmeldefehler");
if (!formular) return;
formular.addEventListener("submit", async (e) => {
e.preventDefault();
const tokenInput = document.getElementById("token-eingabe");
const token = tokenInput?.value.trim() || "";
if (!token) return;
if (fehlerBox) {
fehlerBox.hidden = true;
fehlerBox.textContent = "";
}
setzeToken(token);
try {
// Teste das Token gegen /api/info
const info = await api("/info");
versteckeAnmeldung();
if (loginCallback) loginCallback(info);
} catch (err) {
loescheToken();
if (fehlerBox) {
fehlerBox.textContent = err.abgelehnt
? "Token ungültig oder kürzer als 20 Zeichen."
: `Anmeldefehler: ${err.message}`;
fehlerBox.hidden = false;
}
}
});
}
export function zeigeAnmeldung() {
const anmeldung = document.getElementById("anmeldung");
const anwendung = document.getElementById("anwendung");
const fehlerBox = document.getElementById("anmeldefehler");
const tokenInput = document.getElementById("token-eingabe");
if (anmeldung) anmeldung.hidden = false;
if (anwendung) anwendung.hidden = true;
if (fehlerBox) {
fehlerBox.hidden = true;
fehlerBox.textContent = "";
}
if (tokenInput) {
tokenInput.value = "";
tokenInput.focus();
}
}
export function versteckeAnmeldung() {
const anmeldung = document.getElementById("anmeldung");
const anwendung = document.getElementById("anwendung");
if (anmeldung) anmeldung.hidden = true;
if (anwendung) anwendung.hidden = false;
}
+224
View File
@@ -0,0 +1,224 @@
// ClawdDotNet — Live-Protokollansicht (Logs)
//
// Fortlaufender Stream über /events/logs, Modul- und Level-Filterung, Autoscroll, strikt textContent.
import { api, holeToken } from "../modules/api.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstellePlakette
} from "../modules/dom.js";
let logStream = null;
let modulFilter = "";
let levelFilter = "All";
let autoScroll = true;
let logContainer = null;
let streamStatusPlakette = null;
let pauseButton = null;
let istPausiert = false;
export async function rendereProtokoll() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
trenneLogStream();
const layout = erstelleElement("div", { klasse: "protokoll-ansicht" });
// Kopfleiste mit Filtern und Steuerungen
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
titelGruppe.append(erstelleElement("h1", { text: "Protokoll" }));
streamStatusPlakette = erstellePlakette("Verbinde…", "neutral");
titelGruppe.append(streamStatusPlakette);
const steuerungGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
// Modul-Auswahl
const modulSelect = erstelleElement("select", {
klasse: "eingabe-select",
onchange: (e) => {
modulFilter = e.target.value;
starteLogStream();
}
});
modulSelect.append(erstelleElement("option", { value: "", text: "Alle Module" }));
// Level-Auswahl
const levelSelect = erstelleElement("select", {
klasse: "eingabe-select",
onchange: (e) => {
levelFilter = e.target.value;
starteLogStream();
}
});
const levelOptionen = [
["All", "Alle Stufen"],
["Debug", "Debug"],
["Information", "Info"],
["Warning", "Warnung"],
["Error", "Fehler"],
["Critical", "Kritisch"]
];
for (const [val, label] of levelOptionen) {
levelSelect.append(erstelleElement("option", { value: val, text: label }));
}
// Autoscroll-Checkbox
const autoScrollLabel = erstelleElement("label", { klasse: "checkbox-label gedaempft" });
const autoScrollInput = erstelleElement("input", {
type: "checkbox",
checked: true,
onchange: (e) => {
autoScroll = e.target.checked;
}
});
autoScrollLabel.append(autoScrollInput, erstelleElement("span", { text: " Auto-Scroll" }));
// Pause / Fortsetzen
pauseButton = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
onclick: togglePause
},
erstelleIcon(PFADE.abbrechen, 14),
erstelleElement("span", { text: " Anhalten" })
);
// Leeren
const leerenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Anzeige leeren",
onclick: () => logContainer?.replaceChildren()
},
erstelleIcon(PFADE.leeren, 14),
erstelleElement("span", { text: " Leeren" })
);
steuerungGruppe.append(modulSelect, levelSelect, autoScrollLabel, pauseButton, leerenBtn);
kopfZeile.append(titelGruppe, steuerungGruppe);
logContainer = erstelleElement("div", { klasse: "protokoll-strom code-block" });
layout.append(kopfZeile, logContainer);
container.append(layout);
// Lade verfügbare Module
try {
const module = await api("/logs/modules");
if (Array.isArray(module)) {
for (const m of module) {
modulSelect.append(erstelleElement("option", { value: m, text: m }));
}
}
} catch {
// Nicht kritisch, Filter bleibt auf "Alle Module"
}
starteLogStream();
}
function starteLogStream() {
trenneLogStream();
if (istPausiert) return;
const token = holeToken();
if (!token) return;
let url = `/events/logs?token=${encodeURIComponent(token)}`;
if (modulFilter) url += `&module=${encodeURIComponent(modulFilter)}`;
if (levelFilter) url += `&level=${encodeURIComponent(levelFilter)}`;
logStream = new EventSource(url);
logStream.onopen = () => {
aktualisiereStatus(true);
};
logStream.onerror = () => {
aktualisiereStatus(false);
};
logStream.addEventListener("log", (e) => {
if (istPausiert) return;
try {
const zeile = JSON.parse(e.data);
fuegeLogZeileEin(zeile);
} catch {
fuegeLogZeileEin({ text: e.data });
}
});
}
function fuegeLogZeileEin(eintrag) {
if (!logContainer) return;
const zeile = erstelleElement("div", { klasse: "log-zeile" });
if (eintrag.level) {
const lvl = String(eintrag.level).toLowerCase();
const badgeArt =
lvl.includes("err") || lvl.includes("crit")
? "gefahr"
: lvl.includes("warn")
? "warnung"
: lvl.includes("info")
? "erfolg"
: "neutral";
zeile.append(erstelleElement("span", { klasse: `log-tag tag-${badgeArt}`, text: eintrag.level }));
}
if (eintrag.module) {
zeile.append(erstelleElement("span", { klasse: "log-modul", text: `[${eintrag.module}] ` }));
}
// Wichtig: textContent, kein innerHTML!
const textSpan = erstelleElement("span", { klasse: "log-text", text: eintrag.text || "" });
zeile.append(textSpan);
logContainer.append(zeile);
// Begrenze auf 300 Zeilen im DOM
while (logContainer.children.length > 300) {
logContainer.firstElementChild?.remove();
}
if (autoScroll) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
function togglePause() {
istPausiert = !istPausiert;
if (istPausiert) {
trenneLogStream();
pauseButton.replaceChildren(erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " Fortsetzen" }));
aktualisiereStatus(false, "Pausiert");
} else {
pauseButton.replaceChildren(erstelleIcon(PFADE.abbrechen, 14), erstelleElement("span", { text: " Anhalten" }));
starteLogStream();
}
}
function aktualisiereStatus(offen, text = null) {
if (!streamStatusPlakette) return;
const t = text || (offen ? "Live" : "Getrennt");
const art = offen ? "erfolg" : "neutral";
const neuePlakette = erstellePlakette(t, art);
streamStatusPlakette.replaceWith(neuePlakette);
streamStatusPlakette = neuePlakette;
}
export function trenneLogStream() {
if (logStream) {
logStream.close();
logStream = null;
}
}
@@ -0,0 +1,74 @@
// ClawdDotNet — Platzhalter-Ansichten für noch nicht freigegebene Kern-Endpunkte
//
// Leitfaden Abschnitt 7: Sicherung, Einstellungen und Agenten-Chats (Analyse).
import { erstelleElement, erstelleKarte, erstelleIcon, PFADE } from "../modules/dom.js";
export function rendereSicherung() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" }));
const karte = erstelleKarte("In Vorbereitung", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die API-Endpunkte für die automatisierte Sicherung und Rücksicherung über das WebUI befinden sich in Arbeit."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Bitte verwende für die Sicherung von Instanzen aktuell die Desktop-Oberfläche von ClawdDotNet."
})
]);
layout.append(karte);
container.append(layout);
}
export function rendereEinstellungen() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Instanz-Einstellungen" }));
const karte = erstelleKarte("In Vorbereitung", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die Konfiguration der Instanz (Port, Token, Logging, Budget) erfolgt über die Datei 'InstanceSettings.json' bzw. über den Einstellungsdialog im Desktop-Fenster."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Ein dedizierter Web-Endpunkt zum Schreiben sensibler Zugangsdaten ist für einen späteren Meilenstein vorgesehen."
})
]);
layout.append(karte);
container.append(layout);
}
export function rendereAgentenChats() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Agenten-Kommunikation" }));
const karte = erstelleKarte("Analyse: Interne Agenten-Chats", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die Protokollierung und Auswertung der direkten Kommunikation zwischen zwei Agenten (A2A) wird mit Vorhaben #208 bereitgestellt."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Direkte Chats mit Agenten können im Bereich 'Chat' geführt werden."
})
]);
layout.append(karte);
container.append(layout);
}
@@ -0,0 +1,243 @@
// ClawdDotNet — Hintergrunddienste
//
// Übersicht und Verwaltung der Instanz-Dienste (Port, Protokoll, Status, Ein-/Ausschalten).
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstelleModal,
zeigeMeldung
} from "../modules/dom.js";
let services = [];
let entladeEreignisse = [];
export async function rendereDienste() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "dienste-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Dienste" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerDienstDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Dienst" })
)
);
const tabelleBereich = erstelleElement("div", { id: "dienste-liste-bereich" });
layout.append(kopfZeile, tabelleBereich);
container.append(layout);
await ladeDienste();
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Services") {
ladeDienste();
}
})
);
}
async function ladeDienste() {
const container = document.getElementById("dienste-liste-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Dienste…" })
);
try {
services = await api("/services");
container.replaceChildren();
if (!services || services.length === 0) {
container.append(
erstelleKarte("Keine Dienste", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Für diese Instanz sind noch keine Hintergrunddienste eingerichtet."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
thead.append(
erstelleElement("tr", {},
erstelleElement("th", { text: "Aktiv" }),
erstelleElement("th", { text: "Dienstname" }),
erstelleElement("th", { text: "Typ" }),
erstelleElement("th", { text: "Port" }),
erstelleElement("th", { text: "Beschreibung" }),
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
)
);
const tbody = erstelleElement("tbody");
for (const s of services) {
const tr = erstelleElement("tr");
// Schalter
const tdStatus = erstelleElement("td");
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
const schalterInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(s.enabled),
onchange: async (e) => {
const aktiv = e.target.checked;
try {
await api(`/services/${encodeURIComponent(s.id)}/enabled?enabled=${aktiv}`, {
method: "PUT"
});
s.enabled = aktiv;
zeigeMeldung(`Dienst '${s.name}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
} catch (err) {
e.target.checked = !aktiv;
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
});
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
tdStatus.append(schalterLabel);
const tdName = erstelleElement("td", {}, erstelleElement("strong", { text: s.name }));
const tdTyp = erstelleElement("td", { text: s.type || "—" });
const tdPort = erstelleElement("td", {}, s.port ? erstelleElement("code", { text: String(s.port) }) : erstelleElement("span", { text: "—" }));
const tdDesc = erstelleElement("td", { klasse: "gedaempft", text: s.description || "—" });
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
title: "Dienst löschen",
onclick: () => loescheDienst(s)
},
erstelleIcon(PFADE.muelleimer, 14)
);
tdAktionen.append(loeschenBtn);
tr.append(tdStatus, tdName, tdTyp, tdPort, tdDesc, tdAktionen);
tbody.append(tr);
}
tabelle.append(thead, tbody);
container.append(tabelle);
} catch (err) {
container.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function loescheDienst(s) {
if (!confirm(`Soll der Dienst '${s.name}' (${s.id}) gelöscht werden?`)) return;
try {
await api(`/services/${encodeURIComponent(s.id)}`, { method: "DELETE" });
zeigeMeldung(`Dienst '${s.name}' wurde gelöscht.`, "info");
await ladeDienste();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
function oeffneNeuerDienstDialog() {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const nameFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. Webhook-Empfänger"
});
const typFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. HTTP, TCP, Socket"
});
const portFeld = erstelleElement("input", {
type: "number",
min: "1",
max: "65535",
placeholder: "z.B. 8081 (optional)"
});
const descFeld = erstelleElement("textarea", {
rows: "2",
placeholder: "Beschreibung des Dienstes…"
});
formular.append(
erstelleElement("label", { text: "Dienstname:" }),
nameFeld,
erstelleElement("label", { text: "Diensttyp:" }),
typFeld,
erstelleElement("label", { text: "Port (optional):" }),
portFeld,
erstelleElement("label", { text: "Beschreibung:" }),
descFeld
);
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
});
const anlegenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Dienst anlegen"
});
const modal = erstelleModal("Neuen Hintergrunddienst anlegen", formular, [abbrechenBtn, anlegenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
anlegenBtn.disabled = true;
try {
await api("/services", {
method: "POST",
body: {
name: nameFeld.value.trim(),
type: typFeld.value.trim(),
port: portFeld.value ? parseInt(portFeld.value, 10) : 0,
description: descFeld.value.trim()
}
});
modal.schliessen();
zeigeMeldung(`Dienst '${nameFeld.value.trim()}' angelegt.`, "erfolg");
await ladeDienste();
} catch (err) {
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
} finally {
anlegenBtn.disabled = false;
}
});
modal.oeffnen();
}
@@ -0,0 +1,215 @@
// ClawdDotNet — Freigabestelle (Staging)
//
// Überprüfung und Genehmigung / Ablehnung von sensiblen Werkzeug-Aufrufen.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
let wartendeAufrufe = [];
export async function holeWartendeAnzahl() {
try {
const liste = await api("/staging");
return Array.isArray(liste) ? liste.length : 0;
} catch {
return 0;
}
}
export async function rendereStaging() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "staging-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Freigabestelle" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
onclick: ladeFreigaben
},
erstelleIcon(PFADE.aktualisieren, 16),
erstelleElement("span", { text: " Aktualisieren" })
)
);
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
layout.append(kopfZeile, listeBereich);
container.append(layout);
await ladeFreigaben();
}
async function ladeFreigaben() {
const container = document.getElementById("staging-liste-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Freigaben…" })
);
try {
wartendeAufrufe = await api("/staging");
container.replaceChildren();
// Aktualisiere Badge in Sidebar
aktualisiereStagingBadge(wartendeAufrufe.length);
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
container.append(
erstelleKarte("Keine offenen Freigaben", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Aktuell warten keine Werkzeug-Aufrufe auf eine manuelle Bestätigung."
})
])
);
return;
}
for (const anfrage of wartendeAufrufe) {
const kopfAktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const freigebenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer klein",
onclick: () => bestaetigeFreigabe(anfrage.id)
},
erstelleIcon(PFADE.haken, 14),
erstelleElement("span", { text: " Freigeben" })
);
const ablehnenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
onclick: () => oeffneAblehnenDialog(anfrage.id)
},
erstelleIcon(PFADE.abbrechen, 14),
erstelleElement("span", { text: " Ablehnen" })
);
kopfAktionen.append(freigebenBtn, ablehnenBtn);
const datenliste = erstelleElement("dl", { klasse: "datenliste" });
datenliste.append(
erstelleElement("dt", { text: "Aufruf-ID" }),
erstelleElement("dd", { text: String(anfrage.id) }),
erstelleElement("dt", { text: "Agent" }),
erstelleElement("dd", { text: anfrage.agentId || "—" }),
erstelleElement("dt", { text: "Werkzeug" }),
erstelleElement("dd", { text: `${anfrage.toolName} → ${anfrage.methodName}` }),
erstelleElement("dt", { text: "Zeitpunkt" }),
erstelleElement("dd", { text: formatiereDatumZeit(anfrage.requestedAt) })
);
const argBox = erstelleElement("div", { klasse: "staging-argumente" });
argBox.append(erstelleElement("strong", { text: "Eingefrorene Argumente:" }));
// Strikt textContent! Argumente können Fremdtext sein
const pre = erstelleElement("pre", {
klasse: "code-block",
text: JSON.stringify(anfrage.arguments, null, 2)
});
argBox.append(pre);
const karte = erstelleKarte(`${anfrage.toolName}.${anfrage.methodName}`, [datenliste, argBox], {
aktionen: kopfAktionen
});
container.append(karte);
}
} catch (err) {
container.replaceChildren(
erstelleKarte("Freigabestelle nicht aktiv", [
erstelleElement("p", {
klasse: "gedaempft",
text: err.message || "In dieser Instanz ist keine Freigabestelle eingerichtet."
})
])
);
}
}
function aktualisiereStagingBadge(anzahl) {
const badge = document.getElementById("staging-badge");
if (!badge) return;
if (anzahl > 0) {
badge.textContent = String(anzahl);
badge.hidden = false;
} else {
badge.hidden = true;
}
}
async function bestaetigeFreigabe(id) {
try {
const res = await api(`/staging/${id}/approve`, { method: "POST" });
zeigeMeldung(`Aufruf freigegeben: ${res?.message ?? "Erfolgreich ausgeführt"}`, "erfolg");
await ladeFreigaben();
} catch (err) {
zeigeMeldung(`Freigabe fehlgeschlagen: ${err.message}`, "gefahr");
}
}
function oeffneAblehnenDialog(id) {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const grundInput = erstelleElement("input", {
type: "text",
placeholder: "Begründung für die Ablehnung an den Agenten…"
});
formular.append(
erstelleElement("label", { text: "Begründung (optional):" }),
grundInput
);
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
});
const ablehnenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche gefahr",
text: "Aufruf ablehnen"
});
const modal = erstelleModal("Werkzeug-Aufruf ablehnen", formular, [abbrechenBtn, ablehnenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
ablehnenBtn.disabled = true;
try {
await api(`/staging/${id}/reject`, {
method: "POST",
body: { reason: grundInput.value.trim() }
});
modal.schliessen();
zeigeMeldung("Aufruf wurde abgelehnt.", "info");
await ladeFreigaben();
} catch (err) {
zeigeMeldung(`Ablehnen fehlgeschlagen: ${err.message}`, "gefahr");
} finally {
ablehnenBtn.disabled = false;
}
});
modal.oeffnen();
}
+397
View File
@@ -0,0 +1,397 @@
// ClawdDotNet — Aufgaben & Geplante Läufe
//
// Zwei Bereiche: Taskboard (Aufgaben) und Geplante Läufe (Jobs mit Cron & Sofortausführung).
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
let aktiverReiter = "jobs"; // "jobs" oder "taskboard"
let jobsListe = [];
let entladeEreignisse = [];
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" });
// Kopf mit Reiter-Umschaltung und Aktionen
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
const titel = erstelleElement("h1", { text: "Aufgaben & Jobs" });
const reiterLeiste = erstelleElement("div", { klasse: "reiter-leiste" });
const reiterJobs = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "jobs" ? "aktiv" : ""}`,
text: "Geplante Läufe (Jobs)",
onclick: () => wechsleReiter("jobs")
});
const reiterTaskboard = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "taskboard" ? "aktiv" : ""}`,
text: "Taskboard",
onclick: () => wechsleReiter("taskboard")
});
reiterLeiste.append(reiterJobs, reiterTaskboard);
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const neuerJobBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerJobDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Job" })
);
aktionen.append(neuerJobBtn);
kopfZeile.append(titel, reiterLeiste, aktionen);
const inhaltBereich = erstelleElement("div", { id: "aufgaben-bereich-inhalt" });
layout.append(kopfZeile, inhaltBereich);
container.append(layout);
await ladeAktivenBereich();
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Jobs" && aktiverReiter === "jobs") {
ladeAktivenBereich();
}
})
);
}
function wechsleReiter(reiter) {
aktiverReiter = reiter;
document.querySelectorAll(".reiter-schaltflaeche").forEach((r) => r.classList.remove("aktiv"));
if (reiter === "jobs") {
document.querySelector(".reiter-schaltflaeche:first-child")?.classList.add("aktiv");
} else {
document.querySelector(".reiter-schaltflaeche:last-child")?.classList.add("aktiv");
}
ladeAktivenBereich();
}
async function ladeAktivenBereich() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
container.replaceChildren();
if (aktiverReiter === "jobs") {
await rendereJobs(container);
} else {
await rendereTaskboard(container);
}
}
// ─── Jobs / Geplante Läufe ───
async function rendereJobs(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade geplante Läufe…" })
);
try {
jobsListe = await api("/jobs");
container.replaceChildren();
if (!jobsListe || jobsListe.length === 0) {
container.append(
erstelleKarte("Keine geplanten Läufe", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Es sind noch keine regelmäßigen Agenten-Läufe oder Tool-Jobs eingerichtet."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
const trKopf = erstelleElement("tr");
trKopf.append(
erstelleElement("th", { text: "Status" }),
erstelleElement("th", { text: "Kennung / Art" }),
erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Cron-Muster" }),
erstelleElement("th", { text: "Nächster Lauf (UTC)" }),
erstelleElement("th", { text: "Letzter Lauf (Lokal)" }),
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
);
thead.append(trKopf);
const tbody = erstelleElement("tbody");
for (const job of jobsListe) {
const tr = erstelleElement("tr");
// Schalter Ein/Aus
const tdStatus = erstelleElement("td");
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
const schalterInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(job.enabled),
onchange: async (e) => {
const neuAktiv = e.target.checked;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}/enabled?agentId=${encodeURIComponent(job.agentId)}&enabled=${neuAktiv}`,
{ method: "PUT" }
);
job.enabled = neuAktiv;
zeigeMeldung(`Job '${job.jobId}' ${neuAktiv ? "aktiviert" : "pausiert"}.`, "info");
} catch (err) {
e.target.checked = !neuAktiv;
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
});
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
tdStatus.append(schalterLabel);
// Name / Art
const tdName = erstelleElement("td");
tdName.append(
erstelleElement("strong", { text: job.jobId }),
erstelleElement("div", {
klasse: "gedaempft text-klein",
text: job.kind === "toolJob" ? `Werkzeug (${job.toolName || ""})` : "Agent-Aufruf"
})
);
// Agent
const tdAgent = erstelleElement("td", { text: job.agentId });
// Cron
const tdCron = erstelleElement("td");
tdCron.append(erstelleElement("code", { text: job.cron }));
// Zeiten
const tdNext = erstelleElement("td", {
text: job.nextRunUtc ? formatiereDatumZeit(job.nextRunUtc) : "—"
});
const tdLast = erstelleElement("td", {
text: job.lastRunLocal ? formatiereDatumZeit(job.lastRunLocal) : "—"
});
// Aktionen (Sofortlauf, Löschen)
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal", stil: "justify-content: flex-end;" });
const ausfuehrenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
title: "Job jetzt sofort manuell ausführen",
onclick: () => fuehreJobAus(job.jobId)
},
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Start" })
);
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
title: "Job löschen",
onclick: () => loescheJob(job)
},
erstelleIcon(PFADE.muelleimer, 14)
);
aktionenGruppe.append(ausfuehrenBtn, loeschenBtn);
tdAktionen.append(aktionenGruppe);
tr.append(tdStatus, tdName, tdAgent, tdCron, tdNext, tdLast, tdAktionen);
tbody.append(tr);
}
tabelle.append(thead, tbody);
container.append(tabelle);
} catch (err) {
container.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function fuehreJobAus(jobId) {
try {
const res = await api(`/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST" });
zeigeMeldung(`Job '${jobId}' angestoßen: ${res?.message ?? "Erfolgreich"}`, "erfolg");
} catch (err) {
zeigeMeldung(`Ausführung fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function loescheJob(job) {
if (!confirm(`Soll der geplante Job '${job.jobId}' gelöscht werden?`)) return;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}?agentId=${encodeURIComponent(job.agentId)}&kind=${encodeURIComponent(job.kind)}`,
{ method: "DELETE" }
);
zeigeMeldung(`Job '${job.jobId}' wurde gelöscht.`, "info");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function oeffneNeuerJobDialog() {
let agenten = [];
try {
agenten = await api("/agents");
} catch {}
const formular = erstelleElement("form", { klasse: "formular-raster" });
const agentSelect = erstelleElement("select", { required: true });
for (const a of agenten) {
agentSelect.append(erstelleElement("option", { value: a.agentId, text: `${a.displayName} (${a.agentId})` }));
}
const kindSelect = erstelleElement("select");
kindSelect.append(
erstelleElement("option", { value: "agentWakeup", text: "Agent Wakeup (Regelmäßiger Aufruf)" }),
erstelleElement("option", { value: "toolJob", text: "Tool Job (Werkzeug-Ausführung)" })
);
const cronInput = erstelleElement("input", {
type: "text",
required: true,
value: "0 * * * *",
placeholder: "z.B. 0 * * * * (stündlich)"
});
const taskMsgInput = erstelleElement("textarea", {
rows: "2",
placeholder: "Optionale Start-Nachricht / Anweisung an den Agenten…"
});
formular.append(
erstelleElement("label", { text: "Ziel-Agent:" }),
agentSelect,
erstelleElement("label", { text: "Art des Jobs:" }),
kindSelect,
erstelleElement("label", { text: "Cron-Ausdruck:" }),
cronInput,
erstelleElement("label", { text: "Aufgaben-Nachricht:" }),
taskMsgInput
);
const abbrechenBtn = erstelleElement(
"button",
{
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
}
);
const anlegenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Job erstellen"
});
const modal = erstelleModal("Neuen geplanten Job anlegen", formular, [abbrechenBtn, anlegenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
anlegenBtn.disabled = true;
try {
await api("/jobs", {
method: "POST",
body: {
agentId: agentSelect.value,
kind: kindSelect.value,
cron: cronInput.value.trim(),
taskMessage: taskMsgInput.value.trim(),
runOnStart: false
}
});
modal.schliessen();
zeigeMeldung("Geplanter Job wurde erstellt.", "erfolg");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Fehler beim Erstellen: ${err.message}`, "gefahr");
} finally {
anlegenBtn.disabled = false;
}
});
modal.oeffnen();
}
// ─── Taskboard ───
async function rendereTaskboard(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Taskboard…" })
);
try {
const tasks = await api("/tasks?limit=50");
container.replaceChildren();
if (!tasks || tasks.length === 0) {
container.append(
erstelleKarte("Taskboard leer", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Aktuell liegen keine offenen Aufgaben im gemeinsamen Arbeitsbereich."
})
])
);
return;
}
const raster = erstelleElement("div", { klasse: "taskboard-raster" });
for (const task of tasks) {
const karte = erstelleKarte(task.title || `Aufgabe ${task.id}`, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: `ID: ${task.id} · Agent: ${task.agentId || "—"}` }),
erstelleElement("div", {
klasse: "task-status-zeile",
stil: "margin-top: 8px;"
}, erstellePlakette(task.status || "Offen", task.status === "Done" ? "erfolg" : "neutral")),
erstelleElement("div", {
klasse: "gedaempft text-klein",
stil: "margin-top: 8px;",
text: `Erstellt: ${formatiereDatumZeit(task.createdAt)}`
})
]);
raster.append(karte);
}
container.append(raster);
} catch (err) {
container.replaceChildren(
erstelleKarte("Taskboard nicht verfügbar", [
erstelleElement("p", {
klasse: "gedaempft",
text: err.message || "In dieser Instanz ist kein Taskboard aktiv (kein gemeinsamer Arbeitsbereich)."
})
])
);
}
}
+176
View File
@@ -0,0 +1,176 @@
// ClawdDotNet — Token-Verbrauch & Analyse
//
// Diagramme (CSS/DOM-Balken ohne externe Bibliothek), Statistiken und Läufetabelle.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
formatiereDatumZeit,
formatiereTokens,
formatiereKosten
} from "../modules/dom.js";
export async function rendereVerbrauch() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
layout.append(erstelleElement("h1", { text: "Token-Verbrauch & Analyse" }));
const inhaltBereich = erstelleElement("div", { id: "verbrauch-inhalt-bereich" });
layout.append(inhaltBereich);
container.append(layout);
await ladeVerbrauch();
}
async function ladeVerbrauch() {
const container = document.getElementById("verbrauch-inhalt-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Verbrauchsdaten…" })
);
try {
const laeufe = await api("/usage?limit=100");
container.replaceChildren();
if (!laeufe || laeufe.length === 0) {
container.append(
erstelleKarte("Keine Daten", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Noch keine Token-Verbrauchsdaten aufgezeichnet."
})
])
);
return;
}
// Statistiken berechnen
let summeTokens = 0;
let summePrompt = 0;
let summeCompletion = 0;
let summeKosten = 0;
const nachAgent = new Map();
const nachModell = new Map();
for (const l of laeufe) {
summeTokens += l.totalTokens || 0;
summePrompt += l.promptTokens || 0;
summeCompletion += l.completionTokens || 0;
summeKosten += Number(l.cost || 0);
const agentKey = l.agentId || "Unbekannt";
nachAgent.set(agentKey, (nachAgent.get(agentKey) || 0) + (l.totalTokens || 0));
const modellKey = l.model || "Unbekannt";
nachModell.set(modellKey, (nachModell.get(modellKey) || 0) + (l.totalTokens || 0));
}
// 1. Kacheln (Statistiken)
const kachelGitter = erstelleElement("div", { klasse: "kachel-gitter" });
kachelGitter.append(
erstelleStatKachel("Gesamtkosten (100 Läufe)", formatiereKosten(summeKosten)),
erstelleStatKachel("Tokens gesamt", formatiereTokens(summeTokens)),
erstelleStatKachel("Eingabe-Tokens (Prompt)", formatiereTokens(summePrompt)),
erstelleStatKachel("Ausgabe-Tokens (Completion)", formatiereTokens(summeCompletion))
);
// 2. Balkendiagramme (rein CSS/DOM)
const diagrammGitter = erstelleElement("div", { klasse: "diagramm-gitter" });
const agentBalken = erstelleBalkenDiagramm("Tokens nach Agent", nachAgent, summeTokens);
const modellBalken = erstelleBalkenDiagramm("Tokens nach Modell", nachModell, summeTokens);
diagrammGitter.append(agentBalken, modellBalken);
// 3. Läufe-Tabelle
const tabelleKarte = erstelleElement("div", { klasse: "karte" });
tabelleKarte.append(erstelleElement("h2", { text: "Letzte Agenten-Läufe" }));
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
thead.append(
erstelleElement("tr", {},
erstelleElement("th", { text: "Zeitpunkt" }),
erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Modell" }),
erstelleElement("th", { text: "Prompt", stil: "text-align: right;" }),
erstelleElement("th", { text: "Completion", stil: "text-align: right;" }),
erstelleElement("th", { text: "Gesamt", stil: "text-align: right;" }),
erstelleElement("th", { text: "Kosten", stil: "text-align: right;" })
)
);
const tbody = erstelleElement("tbody");
for (const lauf of laeufe) {
const tr = erstelleElement("tr");
tr.append(
erstelleElement("td", { text: formatiereDatumZeit(lauf.timestamp) }),
erstelleElement("td", {}, erstelleElement("strong", { text: lauf.agentId })),
erstelleElement("td", { klasse: "gedaempft text-klein", text: lauf.model }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.promptTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.completionTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.totalTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereKosten(lauf.cost) })
);
tbody.append(tr);
}
tabelle.append(thead, tbody);
tabelleKarte.append(tabelle);
container.append(kachelGitter, diagrammGitter, tabelleKarte);
} catch (err) {
container.replaceChildren(
erstelleKarte("Verbrauchsdaten nicht verfügbar", [
erstelleElement("p", { klasse: "gedaempft", text: err.message })
])
);
}
}
function erstelleStatKachel(titel, wert) {
const kachel = erstelleElement("div", { klasse: "stat-kachel karte" });
kachel.append(
erstelleElement("div", { klasse: "stat-titel gedaempft", text: titel }),
erstelleElement("div", { klasse: "stat-wert", text: wert })
);
return kachel;
}
function erstelleBalkenDiagramm(titel, datenMap, gesamt) {
const karte = erstelleKarte(titel);
const container = erstelleElement("div", { klasse: "balken-liste" });
const sortiert = [...datenMap.entries()].sort((a, b) => b[1] - a[1]);
for (const [name, wert] of sortiert) {
const prozent = gesamt > 0 ? ((wert / gesamt) * 100).toFixed(1) : "0";
const zeile = erstelleElement("div", { klasse: "balken-zeile" });
const meta = erstelleElement("div", { klasse: "balken-meta" });
meta.append(
erstelleElement("span", { klasse: "balken-name", text: name }),
erstelleElement("span", { klasse: "balken-wert gedaempft", text: `${formatiereTokens(wert)} (${prozent}%)` })
);
const spur = erstelleElement("div", { klasse: "balken-spur" });
const fuellung = erstelleElement("div", {
klasse: "balken-fuellung",
stil: `width: ${Math.max(1, Number(prozent))}%;`
});
spur.append(fuellung);
zeile.append(meta, spur);
container.append(zeile);
}
karte.append(container);
return karte;
}