- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming. - Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften). - ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert. - Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
This commit is contained in:
@@ -0,0 +1,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>
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)."
|
||||
})
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user