Weboberflaeche hinzugefuegt und Fachlogik nach App herausgeloest (#493, #494)

- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming.

- Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften).

- ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert.

- Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
This commit is contained in:
Richard
2026-09-07 21:56:02 +02:00
parent cffd6f4b77
commit 1e9cb789c8
64 changed files with 10543 additions and 554 deletions
+73 -4
View File
@@ -63,6 +63,12 @@ public sealed class AppHost : IAsyncDisposable
public TaskScanner? Scanner { get; private init; }
public StagingService? Staging { get; private init; }
/// <summary>
/// Das Taskboard. Null, wenn den Agenten dieser Instanz ein gemeinsamer
/// Arbeitsbereich fehlt — dann gibt es keinen Ort, an dem Aufgaben lägen.
/// </summary>
public TaskboardService? Board { get; private init; }
public SqliteUsageRepository? Usage { get; private init; }
public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; }
@@ -98,6 +104,68 @@ public sealed class AppHost : IAsyncDisposable
/// </summary>
public LicenseWatch? LicenseWatch { get; private set; }
/// <summary>
/// Meldet, dass die Instanzkonfiguration geschrieben wurde.
///
/// <para>Es gibt dieses Ereignis erst, seit es zwei Oberflächen gibt.
/// <see cref="Instance"/> ist ein veränderliches Objekt, das beide teilen — die
/// Ansichtsmodelle lesen es aber einmal beim Aufbau in ihre Listen. Ohne eine
/// Meldung erschiene ein in der Weboberfläche angelegter Agent im laufenden Fenster
/// nicht, und umgekehrt genauso.</para>
///
/// <para><b>Kommt auf dem Faden des Verursachers</b>, nicht auf dem der Oberfläche.
/// Wer eine <c>ObservableCollection</c> daran hängt, geht über den Dispatcher.</para>
/// </summary>
public event Action<InstanceChange>? InstanceChanged;
/// <summary>
/// Schreibt die Instanzkonfiguration und sagt Bescheid.
///
/// <para>Der einzige Weg, auf dem <see cref="Instance"/> gespeichert werden soll.
/// <c>Directories.SaveInstanceConfig</c> von Hand zu rufen schreibt zwar dieselbe
/// Datei, lässt aber die andere Oberfläche in Unkenntnis.</para>
/// </summary>
public void SaveInstance(InstanceChange change)
{
Directories.SaveInstanceConfig(InstancePath, Instance);
InstanceChanged?.Invoke(change);
}
// ─── Verwaltung ───
//
// Die Regeln zum Anlegen, Ändern und Ausführen — bis zum 2026-09-06 in den
// Avalonia-Ansichtsmodellen, jetzt hier. Der Grund ist die zweite Oberfläche: Was
// dort stand, hätte sie ein zweites Mal schreiben müssen.
//
// Je Aufbau genau eine Ausprägung, und deshalb hier und nicht beim Aufrufer: Der
// RunCoordinator führt den Verlauf und merkt sich die letzten Handläufe. Zwei
// Ausprägungen hiessen zwei Verläufe, von denen jede Oberfläche einen anderen zeigt.
private readonly Lazy<AgentAdministration> _agentAdmin;
private readonly Lazy<ServiceAdministration> _serviceAdmin;
private readonly Lazy<JobAdministration> _jobAdmin;
private readonly Lazy<RunCoordinator> _runs;
public AppHost()
{
_agentAdmin = new Lazy<AgentAdministration>(() => new AgentAdministration(this));
_serviceAdmin = new Lazy<ServiceAdministration>(() => new ServiceAdministration(this));
_jobAdmin = new Lazy<JobAdministration>(() => new JobAdministration(this));
_runs = new Lazy<RunCoordinator>(() => new RunCoordinator(this));
}
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
public AgentAdministration AgentAdmin => _agentAdmin.Value;
/// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary>
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
/// <summary>Geplante Läufe anlegen, ändern, entfernen, abschalten.</summary>
public JobAdministration JobAdmin => _jobAdmin.Value;
/// <summary>„Jetzt ausführen" samt Verlauf.</summary>
public RunCoordinator Runs => _runs.Value;
/// <summary>Was der Aufrufer beim Start erfragen muss.</summary>
public sealed class Callbacks
{
@@ -362,7 +430,7 @@ public sealed class AppHost : IAsyncDisposable
engine.LoadPersistedChats();
var (scanner, staging) = BuildTaskboard(
var (scanner, staging, board) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, loggerFactory, logger);
@@ -378,6 +446,7 @@ public sealed class AppHost : IAsyncDisposable
Engine = engine,
Scanner = scanner,
Staging = staging,
Board = board,
Usage = usage,
TelegramClient = telegram,
Status = new OpenRouterStatusService(instance.OpenRouterApiKey)
@@ -394,7 +463,7 @@ public sealed class AppHost : IAsyncDisposable
/// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
/// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
/// </summary>
private static (TaskScanner?, StagingService?) BuildTaskboard(
private static (TaskScanner?, StagingService?, TaskboardService?) BuildTaskboard(
InstanceConfig instance,
SqliteTaskRepository taskRepository,
AgentEngine engine,
@@ -410,7 +479,7 @@ public sealed class AppHost : IAsyncDisposable
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
if (string.IsNullOrWhiteSpace(sharedWorkspace))
return (null, null);
return (null, null, null);
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
@@ -451,7 +520,7 @@ public sealed class AppHost : IAsyncDisposable
}
});
return (scanner, staging);
return (scanner, staging, board);
}
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
@@ -0,0 +1,18 @@
namespace ClawdDotNet.App.Services;
/// <summary>
/// Ergebnis eines verwaltenden Aufrufs: hat es geklappt, und was sagt man dem Benutzer?
///
/// <para>Bewusst keine Ausnahme für den Normalfall „geht nicht": Eine doppelte
/// Agenten-ID ist keine Störung, sondern eine Antwort. Ausnahmen bleiben dem
/// vorbehalten, was wirklich schiefgeht — ein nicht schreibbares Instanzverzeichnis
/// etwa.</para>
///
/// <para>Die Meldung ist für Menschen und steht auf Deutsch. Beide Oberflächen zeigen
/// dieselbe an; wer sie umformuliert, tut es hier und nicht zweimal.</para>
/// </summary>
public readonly record struct AdminResult(bool Ok, string Message)
{
public static AdminResult Success(string message) => new(true, message);
public static AdminResult Failure(string message) => new(false, message);
}
@@ -0,0 +1,261 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.App.Services;
/// <summary>Die Angaben, die einen neuen Agenten ausmachen.</summary>
public sealed record NewAgent(
string AgentId,
string DisplayName,
string Model,
string Description = "");
/// <summary>
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
///
/// <para>Diese Regeln standen bis zum 2026-09-06 in
/// <c>ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs</c> — die Prüfung auf
/// doppelte IDs, das Anlegen, das Speichern. Dort war es nicht falsch, solange es genau
/// eine Oberfläche gab. Mit der Weboberfläche daneben wäre jede dieser Regeln ein
/// zweites Mal geschrieben worden, und ab dem Tag danach wären beide Fassungen
/// auseinandergelaufen.</para>
///
/// <para>Alles, was schreibt, geht über <see cref="AppHost.SaveInstance"/> — nur so
/// erfährt die jeweils andere Oberfläche davon.</para>
/// </summary>
public sealed class AgentAdministration(AppHost host)
{
/// <summary>
/// Die Werkzeuge, die eine Oberfläche zum Ankreuzen anbietet.
///
/// <para>Fest verdrahtet und länger als das, was in einer Instanz tatsächlich
/// registriert ist: Ein Werkzeug soll auch dann in der Liste stehen, wenn es in
/// dieser Instanz gerade nicht geladen ist — sonst verschwände mit dem Werkzeug
/// stillschweigend auch seine Konfiguration aus der Ansicht. Welche davon wirklich
/// da sind, sagt <see cref="ToolDescriptions"/>: dort bleibt die Beschreibung leer.
/// </para>
/// </summary>
public static readonly IReadOnlyList<string> KnownTools =
[
"FileRW", "Telegram", "Mail", "Database", "FTP",
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
public IReadOnlyList<AgentConfig> All => host.Instance.Agents;
public AgentConfig? Find(string agentId)
=> host.Instance.Agents.FirstOrDefault(
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Beschreibungen der tatsächlich geladenen Werkzeuge, nach Namen. Für ein Werkzeug
/// aus <see cref="KnownTools"/>, das hier fehlt, bleibt die Beschreibung leer.
/// </summary>
public IReadOnlyDictionary<string, string> ToolDescriptions()
=> host.Tools.GetAll()
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Legt einen Agenten an — <b>mit Verzeichnis</b>.
///
/// <para>Die ID wird ohne Rücksicht auf Groß- und Kleinschreibung geprüft. Zwei
/// Agenten, die sich nur darin unterscheiden, wären auf der Zielplattform zwar zwei
/// verschiedene Ordner, aber für jeden Menschen derselbe Agent.</para>
///
/// <para><b>Das Verzeichnis ist keine Beigabe, sondern der Ort, an dem ein Agent
/// wohnt.</b> <c>LoadInstanceConfig</c> leert beim Start die Agentenliste und baut
/// sie aus den Ordnern <c>Agents/Agent-*</c> wieder auf — die Liste in
/// <c>InstanceSettings.json</c> wird dabei nicht gelesen. Ein Agent, der nur dort
/// steht, ist nach dem nächsten Start weg. Genau das tat die Oberfläche bis zum
/// 2026-09-06.</para>
/// </summary>
public AdminResult Add(NewAgent request)
{
var agentId = request.AgentId.Trim();
var displayName = request.DisplayName.Trim();
if (agentId.Length == 0)
return AdminResult.Failure("Ohne Agenten-ID geht es nicht.");
if (displayName.Length == 0)
return AdminResult.Failure("Ohne Anzeigenamen geht es nicht.");
if (Find(agentId) is not null)
return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits.");
string agentDir;
try
{
// Legt Ordner, AgentSettings.json, Identity.md, Soul.md und den Eintrag in
// AgentList.json an. Wirft, wenn es den Ordner schon gibt — zwei Agenten mit
// verschiedener ID, aber gleichem Anzeigenamen teilten sich sonst ein
// Verzeichnis.
agentDir = host.Directories.CreateAgent(
host.InstancePath, displayName, request.Description);
}
catch (Exception ex)
{
return AdminResult.Failure($"Verzeichnis konnte nicht angelegt werden: {ex.Message}");
}
var config = host.Directories.LoadAgentConfig(agentDir);
// CreateAgent leitet die ID aus dem Namen ab; hier zählt die, nach der gefragt
// wurde.
config.AgentId = agentId;
config.DisplayName = displayName;
config.Model = request.Model;
config.Description = request.Description;
host.Directories.SaveAgentSettings(agentDir, config);
host.Instance.Agents.Add(config);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
return AdminResult.Success($"Agent '{displayName}' angelegt.");
}
/// <summary>
/// Nimmt den Agenten aus dem Betrieb.
///
/// <para><b>Sein Verzeichnis wird umbenannt, nicht gelöscht</b> — es heißt danach
/// <c>Entfernt-…</c> und wird beim Start nicht mehr eingelesen. Nur aus der
/// Instanzliste zu streichen genügte nicht: Beim nächsten Start stünde der Agent
/// wieder da, weil sein Ordner noch da ist. Ihn zu löschen wäre die andere
/// Übertreibung — im Arbeitsordner steht seine Arbeit.</para>
/// </summary>
public AdminResult Remove(string agentId)
{
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
host.Instance.Agents.Remove(agent);
var folder = agent.AgentDir.Length > 0
? Path.GetFileName(agent.AgentDir)
: InstanceDirectoryManager.BuildAgentFolderName(agent.DisplayName);
string? archived = null;
try
{
archived = host.Directories.ArchiveAgent(host.InstancePath, folder);
}
catch (Exception ex)
{
// Der Agent ist aus der Liste; dass sein Ordner liegen bleibt, ist ein
// Ärgernis und kein Grund, den Aufruf scheitern zu lassen — sonst stünde er
// halb entfernt da. Beim nächsten Start käme er allerdings wieder, und genau
// das muss in der Meldung stehen.
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
return AdminResult.Failure(
$"Agent '{agentId}' aus der Liste genommen, aber sein Verzeichnis ließ sich "
+ $"nicht beiseiteräumen ({ex.Message}). Beim nächsten Start ist er wieder da.");
}
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
return AdminResult.Success(archived is null
? $"Agent '{agentId}' entfernt."
: $"Agent '{agentId}' entfernt. Sein Verzeichnis liegt unter "
+ $"'{Path.GetFileName(archived)}'.");
}
/// <summary>
/// Lässt ein Werkzeug zu oder nimmt es weg.
///
/// <para>Zulassen legt eine leere Konfiguration an — das Werkzeug läuft dann mit
/// seinen Vorgabewerten. Wegnehmen entfernt den Eintrag samt Einstellungen; das ist
/// seit jeher so und der Grund, warum die Oberfläche zwischen „ankreuzen" und
/// „Einstellungen speichern" unterscheidet.</para>
/// </summary>
public AdminResult SetToolEnabled(string agentId, string toolName, bool enabled)
{
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
if (enabled)
{
if (!agent.Tools.ContainsKey(toolName))
agent.Tools[toolName] = new Dictionary<string, object?>();
}
else
{
agent.Tools.Remove(toolName);
}
PersistAgent(agent);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
return AdminResult.Success(enabled
? $"Werkzeug '{toolName}' für '{agent.DisplayName}' zugelassen."
: $"Werkzeug '{toolName}' für '{agent.DisplayName}' entfernt.");
}
/// <summary>
/// Schreibt die Einstellungen eines Werkzeugs.
///
/// <para>Einstellungen zu schreiben heißt, das Werkzeug zu wollen: Es wird dabei
/// zugelassen, falls es das noch nicht war.</para>
/// </summary>
public AdminResult SetToolSettings(
string agentId, string toolName, Dictionary<string, object?> settings)
{
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
agent.Tools[toolName] = settings;
PersistAgent(agent);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
return AdminResult.Success($"Einstellungen für '{toolName}' gespeichert.");
}
/// <summary>
/// Schreibt die Instanzkonfiguration, nachdem der Aufrufer die
/// <see cref="AgentConfig"/>-Objekte selbst verändert hat.
///
/// <para>Für die Avalonia-Oberfläche, deren Ansichtsmodelle über Bindungen direkt auf
/// den Konfigurationsobjekten arbeiten und am Ende nur noch speichern wollen. Die
/// Weboberfläche hat diesen Fall nicht — sie schickt ganze Felder und sollte die
/// Aufrufe oben nehmen.</para>
/// </summary>
public void Save()
{
foreach (var agent in host.Instance.Agents)
PersistAgent(agent);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents));
}
/// <summary>
/// Schreibt einen Agenten dorthin, wo er beim nächsten Start gelesen wird.
///
/// <para><b>Das ist sein eigener Ordner, nicht <c>InstanceSettings.json</c>.</b>
/// <c>LoadInstanceConfig</c> leert die Agentenliste und baut sie aus
/// <c>Agents/Agent-*/AgentSettings.json</c> wieder auf. Bis zum 2026-09-06 rief
/// niemand im laufenden Betrieb <c>SaveAgentSettings</c> — jede Änderung an Modell,
/// Prompt, Budget oder Werkzeugen stand nur in <c>InstanceSettings.json</c> und war
/// nach dem nächsten Start weg. Es sah aus wie Speichern und war keines.</para>
///
/// <para><c>Identity</c> und <c>Soul</c> gehen in ihre eigenen Dateien: Von dort
/// werden sie gelesen, und was in <c>AgentSettings.json</c> danebenstünde, überschriebe
/// der nächste Ladevorgang ohnehin.</para>
/// </summary>
private void PersistAgent(AgentConfig agent)
{
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
// Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des
// Speicherns; er bleibt, wie er ist.
if (string.IsNullOrWhiteSpace(agent.AgentDir) || !Directory.Exists(agent.AgentDir))
return;
host.Directories.SaveAgentSettings(agent.AgentDir, agent);
host.Directories.SaveAgentIdentity(agent.AgentDir, agent.Identity);
host.Directories.SaveAgentSoul(agent.AgentDir, agent.Soul);
}
}
@@ -0,0 +1,30 @@
namespace ClawdDotNet.App;
/// <summary>Welcher Teil der Instanzkonfiguration sich geändert hat.</summary>
public enum InstanceChangeKind
{
/// <summary>Ein Agent kam dazu, fiel weg oder wurde bearbeitet.</summary>
Agents,
/// <summary>Ein Dienst kam dazu, fiel weg oder wurde umgeschaltet.</summary>
Services,
/// <summary>Ein geplanter Lauf (Agent-Wakeup oder Tool-Job) hat sich geändert.</summary>
Jobs,
/// <summary>Etwas an der Instanz selbst — Budget, Grenzen, Weboberfläche.</summary>
Settings
}
/// <summary>
/// Eine Änderung an der Instanzkonfiguration, so grob, wie eine Oberfläche sie braucht,
/// um den richtigen Bereich neu zu laden.
///
/// <para>Bewusst ohne die geänderten Werte: Beide Oberflächen lesen ohnehin aus
/// <c>AppHost.Instance</c>, und ein zweiter Übertragungsweg für dieselben Daten wäre
/// eine zweite Wahrheit. <see cref="Id"/> ist ein Hinweis, keine Zusage — wer ihn nicht
/// auswerten will, lädt den ganzen Bereich neu.</para>
/// </summary>
/// <param name="Kind">Welcher Bereich betroffen ist.</param>
/// <param name="Id">Agent-, Dienst- oder Job-Kennung, sofern es genau eine gibt.</param>
public readonly record struct InstanceChange(InstanceChangeKind Kind, string? Id = null);
@@ -360,6 +360,45 @@ public sealed class InstanceDirectoryManager
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul);
}
/// <summary>
/// Nimmt einen Agenten aus dem Betrieb, ohne seine Arbeit wegzuwerfen.
///
/// <para><b>Warum umbenennen und nicht löschen.</b> Geladen werden Agenten aus den
/// Ordnern <c>Agents/Agent-*</c> — was nicht so heißt, wird nicht geladen. Ein
/// Ordner <c>Entfernt-…</c> ist damit fachlich weg und liegt trotzdem noch da. Im
/// Arbeitsordner eines Agenten steht seine Arbeit, und ein Klick in einer Liste ist
/// keine Entscheidung, sie wegzuwerfen. Wer wirklich löschen will, nimmt
/// <see cref="RemoveAgent"/> — oder den Dateimanager.</para>
///
/// <para>Der Zeitstempel im neuen Namen ist kein Schmuck: Ohne ihn scheitert das
/// zweite Entfernen eines gleichnamigen Agenten daran, dass es das Ziel schon gibt.</para>
/// </summary>
/// <returns>Der neue Pfad, oder <c>null</c>, wenn es den Ordner gar nicht gab.</returns>
public string? ArchiveAgent(string instanceDir, string agentFolderName)
{
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
if (!Directory.Exists(agentDir)) return null;
var bare = agentFolderName.StartsWith("Agent-", StringComparison.Ordinal)
? agentFolderName["Agent-".Length..]
: agentFolderName;
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
var archived = Path.Combine(instanceDir, "Agents", $"Entfernt-{bare}-{stamp}");
Directory.Move(agentDir, archived);
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
if (File.Exists(agentListPath))
{
var list = LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile();
list.Agents.RemoveAll(a => a.FolderName == agentFolderName);
SaveJson(agentListPath, list);
}
return archived;
}
public void RemoveAgent(string instanceDir, string agentFolderName)
{
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
@@ -0,0 +1,260 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Scheduling;
namespace ClawdDotNet.App.Services;
/// <summary>Die zwei Arten geplanter Läufe.</summary>
public enum JobKind
{
/// <summary>Der Agent wird geweckt und arbeitet seine Aufgaben ab. Höchstens einer je Agent.</summary>
AgentWakeup,
/// <summary>Ein Werkzeug wird periodisch abgefragt. Beliebig viele je Agent.</summary>
ToolJob
}
/// <summary>
/// Ein geplanter Lauf, wie ihn eine Oberfläche in einer Liste zeigt.
///
/// <para>Bewusst mit <see cref="DateTime"/> statt einer fertigen Zeichenkette: Wie ein
/// Zeitpunkt aussieht, entscheidet die Anzeige — in einem Fenster anders als in einem
/// Browser, der die Zeitzone des Betrachters kennt. Was die Fachschicht beisteuert, ist
/// der Zeitpunkt selbst.</para>
/// </summary>
/// <param name="CronValid">
/// Falsch, wenn der Ausdruck nicht lesbar ist. Von „kein Ausdruck hinterlegt" zu
/// unterscheiden — dort ist er wahr und <see cref="NextRunUtc"/> leer.
/// </param>
public sealed record JobSummary(
JobKind Kind,
string JobId,
string AgentId,
string AgentName,
string ToolName,
string Cron,
string Message,
bool RunOnStart,
DateTime? NextRunUtc,
bool CronValid,
bool Enabled);
/// <summary>Was einen geplanten Lauf ausmacht — beim Anlegen wie beim Ändern.</summary>
public sealed record JobDefinition(
string AgentId,
JobKind Kind,
string ToolName = "",
string JobTypeId = "",
string Cron = "",
string TaskMessage = "",
bool RunOnStart = false);
/// <summary>
/// Geplante Läufe anlegen, ändern, entfernen und abschalten.
///
/// <para>Stand bis zum 2026-09-06 in
/// <c>ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs</c>. Die Verschiebung ändert
/// nichts an den Regeln — auch nicht daran, dass ein Agent-Wakeup keinen
/// Ein-/Aus-Schalter hat: <see cref="SchedulerConfig"/> kennt keinen, und einen hier zu
/// erfinden hieße, dass der Scanner ihn erst einmal lesen müsste. Sonst liefe ein
/// „abgeschalteter" Wakeup munter weiter — schlimmer als kein Schalter.</para>
/// </summary>
public sealed class JobAdministration(AppHost host)
{
/// <summary>Alle geplanten Läufe der Instanz, Agent für Agent.</summary>
public IReadOnlyList<JobSummary> List()
{
var jobs = new List<JobSummary>();
foreach (var agent in host.Instance.Agents)
{
if (agent.Scheduler is { } scheduler)
{
var (next, valid) = NextRun(scheduler.Cron);
jobs.Add(new JobSummary(
JobKind.AgentWakeup,
WakeupId(agent.AgentId),
agent.AgentId,
agent.DisplayName,
ToolName: "",
scheduler.Cron,
scheduler.TaskMessage,
scheduler.RunOnStart,
next,
valid,
// Ein Wakeup laeuft, solange der Scanner laeuft — er ist der einzige
// periodische Treiber. Ohne ihn steht er, ohne dass jemand ihn
// abgeschaltet haette.
Enabled: host.Scanner is not null));
}
foreach (var job in agent.ToolJobs)
{
var (next, valid) = NextRun(job.Cron);
jobs.Add(new JobSummary(
JobKind.ToolJob,
job.JobId,
agent.AgentId,
agent.DisplayName,
job.ToolName,
job.Cron,
job.JobTypeId,
job.RunOnStart,
next,
valid,
job.Enabled));
}
}
return jobs;
}
/// <summary>
/// Der nächste fällige Zeitpunkt in UTC.
///
/// <para>Ein leerer Ausdruck ist kein Fehler — er heißt „nie". Ein unlesbarer schon;
/// deshalb die zwei Rückgaben statt eines <c>null</c>, das beides bedeuten müsste.</para>
/// </summary>
public static (DateTime? Next, bool Valid) NextRun(string? cron)
{
if (string.IsNullOrWhiteSpace(cron)) return (null, true);
try
{
return (CronExpression.Parse(cron).GetNextOccurrence(DateTime.UtcNow), true);
}
catch
{
return (null, false);
}
}
/// <summary>
/// Die Kennung eines Agent-Wakeups. Er hat keine eigene — es gibt höchstens einen je
/// Agent —, aber eine Liste, in der eine Zeile ausgewählt werden kann, braucht eine.
/// </summary>
public static string WakeupId(string agentId) => $"agent_{agentId}";
public AdminResult Add(JobDefinition definition)
{
if (FindAgent(definition.AgentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{definition.AgentId}'.");
var (_, valid) = NextRun(definition.Cron);
if (!valid)
return AdminResult.Failure($"Der Zeitplan '{definition.Cron}' ist nicht lesbar.");
if (definition.Kind == JobKind.ToolJob)
{
agent.ToolJobs.Add(new ToolJobConfig
{
ToolName = definition.ToolName,
JobTypeId = definition.JobTypeId,
Cron = definition.Cron,
RunOnStart = definition.RunOnStart,
Enabled = true
});
}
else
{
agent.Scheduler = new SchedulerConfig
{
Cron = definition.Cron,
RunOnStart = definition.RunOnStart,
TaskMessage = definition.TaskMessage
};
}
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, definition.AgentId));
return AdminResult.Success(definition.Kind == JobKind.ToolJob
? $"Tool-Job '{definition.JobTypeId}' für '{agent.DisplayName}' angelegt."
: $"Wakeup für '{agent.DisplayName}' angelegt.");
}
public AdminResult Update(string jobId, JobDefinition definition)
{
if (FindAgent(definition.AgentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{definition.AgentId}'.");
var (_, valid) = NextRun(definition.Cron);
if (!valid)
return AdminResult.Failure($"Der Zeitplan '{definition.Cron}' ist nicht lesbar.");
if (definition.Kind == JobKind.ToolJob)
{
if (agent.ToolJobs.FirstOrDefault(j => j.JobId == jobId) is not { } job)
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
job.ToolName = definition.ToolName;
job.JobTypeId = definition.JobTypeId;
job.Cron = definition.Cron;
job.RunOnStart = definition.RunOnStart;
}
else
{
if (agent.Scheduler is not { } scheduler)
return AdminResult.Failure($"'{agent.DisplayName}' hat keinen Wakeup.");
scheduler.Cron = definition.Cron;
scheduler.TaskMessage = definition.TaskMessage;
scheduler.RunOnStart = definition.RunOnStart;
}
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, definition.AgentId));
return AdminResult.Success("Zeitplan gespeichert.");
}
public AdminResult Remove(string agentId, string jobId, JobKind kind)
{
if (FindAgent(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
if (kind == JobKind.ToolJob)
{
if (agent.ToolJobs.RemoveAll(j => j.JobId == jobId) == 0)
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
}
else
{
if (agent.Scheduler is null)
return AdminResult.Failure($"'{agent.DisplayName}' hat keinen Wakeup.");
agent.Scheduler = null;
}
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, agentId));
return AdminResult.Success("Geplanter Lauf entfernt.");
}
/// <summary>
/// Schaltet einen Tool-Job an oder ab, ohne ihn zu entfernen.
///
/// <para>Nur Tool-Jobs. Warum ein Wakeup das nicht kann, steht in der
/// Klassenbeschreibung.</para>
/// </summary>
public AdminResult SetEnabled(string agentId, string jobId, bool enabled)
{
if (FindAgent(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
if (agent.ToolJobs.FirstOrDefault(j => j.JobId == jobId) is not { } job)
return AdminResult.Failure(
"Nur Tool-Jobs lassen sich abschalten. "
+ "Ein Agent-Wakeup wird über die Agenten-Seite entfernt.");
job.Enabled = enabled;
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, agentId));
return AdminResult.Success(enabled
? $"Job '{job.JobTypeId}' aktiviert."
: $"Job '{job.JobTypeId}' deaktiviert.");
}
private AgentConfig? FindAgent(string agentId)
=> host.Instance.Agents.FirstOrDefault(
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase));
}
@@ -0,0 +1,148 @@
using System.Collections.Concurrent;
using ClawdDotNet.App.Models;
namespace ClawdDotNet.App.Services;
/// <summary>
/// „Jetzt ausführen" — von Hand angestoßene Läufe, gleich von welcher Oberfläche.
///
/// <para>Stand bis zum 2026-09-06 zweimal in den Ansichtsmodellen: einmal für den
/// Agenten (<c>AgentsPageViewModel.RunAgentNowAsync</c>), einmal für den geplanten Lauf
/// (<c>TasksPageViewModel.RunJobNowAsync</c>). Beide Fassungen taten fast dasselbe und
/// unterschieden sich in dem, was sie in den Verlauf schrieben.</para>
///
/// <para>Die Unterscheidung, die diese Klasse trägt: <b>ein Tool-Job geht über den
/// Scanner, ein Agentenlauf über die Engine.</b> Der Scanner ist der einzige periodische
/// Treiber (A1) und kennt die Sperre auf der Aufgabe — ein Tool-Job an ihm vorbei liefe
/// womöglich neben dem geplanten Lauf desselben Jobs.</para>
/// </summary>
public sealed class RunCoordinator
{
private readonly AppHost _host;
/// <summary>
/// Wann ein Lauf zuletzt von Hand angestoßen wurde, je Job-Kennung. Nur im
/// Arbeitsspeicher und bewusst nicht in der Instanzdatei — es ist eine Notiz für die
/// Anzeige, keine Konfiguration. Beide Oberflächen teilen sie sich, damit ein im
/// Browser angestoßener Lauf auch im Fenster als „zuletzt" auftaucht.
/// </summary>
private readonly ConcurrentDictionary<string, DateTime> _lastRuns = new();
public RunCoordinator(AppHost host)
{
_host = host;
History = new JobHistoryService(host.InstancePath);
}
/// <summary>Der Verlauf von Hand angestoßener Läufe. Fasst sich selbst threadsicher an.</summary>
public JobHistoryService History { get; }
public DateTime? LastRun(string jobId)
=> _lastRuns.TryGetValue(jobId, out var when) ? when : null;
/// <summary>
/// Weckt einen Agenten von Hand.
///
/// <para>Ohne eigene Nachricht wird die des geplanten Laufs genommen — der Handlauf
/// soll zeigen, was der geplante täte.</para>
/// </summary>
public async Task<AdminResult> RunAgentAsync(
string agentId, string? message, CancellationToken ct)
{
if (_host.Instance.Agents.FirstOrDefault(
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase)) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
if (_host.Engine is null)
return AdminResult.Failure("Agent-Engine ist nicht aktiv (kein API-Key).");
var taskMessage = string.IsNullOrWhiteSpace(message)
? agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus."
: message;
try
{
var result = await _host.Engine.RunAsync(
agent, taskMessage, _host.Instance.InstanceId, ct);
_lastRuns[JobAdministration.WakeupId(agent.AgentId)] = DateTime.Now;
History.Add(new JobHistoryEntry
{
JobName = "Agent Wakeup",
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = taskMessage,
Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)",
Status = "Manual"
});
return AdminResult.Success(
$"'{agent.DisplayName}' abgeschlossen: {result.Status} "
+ $"({result.StepCount} Schritte, {result.TokensUsed:N0} Tokens).");
}
catch (OperationCanceledException)
{
return AdminResult.Failure($"Lauf von '{agent.DisplayName}' abgebrochen.");
}
catch (Exception ex)
{
return AdminResult.Failure($"Lauf fehlgeschlagen: {ex.Message}");
}
}
/// <summary>
/// Stößt einen Tool-Job von Hand an — über den Scanner, nicht an ihm vorbei.
///
/// <para>„Nicht bereit" ist keine Störung: Der Scanner gibt <c>false</c> zurück, wenn
/// die Aufgabe gerade von jemand anderem gehalten wird oder gar nicht existiert.
/// Genau dafür ist die Sperre da.</para>
/// </summary>
public async Task<AdminResult> RunToolJobAsync(
string agentId, string jobId, CancellationToken ct)
{
if (_host.Instance.Agents.FirstOrDefault(
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase)) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
if (agent.ToolJobs.FirstOrDefault(j => j.JobId == jobId) is not { } job)
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
if (_host.Scanner is null)
return AdminResult.Failure("Scanner ist nicht aktiv.");
try
{
var ran = await _host.Scanner.RunTaskNowAsync($"tj-{agent.AgentId}-{job.JobId}", ct);
var info = ran ? "Ausgeführt" : "Nicht bereit";
_lastRuns[job.JobId] = DateTime.Now;
History.Add(new JobHistoryEntry
{
JobName = job.JobTypeId,
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = $"Tool Job: {job.ToolName}",
Info = info,
Status = "Manual"
});
return AdminResult.Success($"Tool-Job '{job.JobTypeId}' abgeschlossen ({info}).");
}
catch (OperationCanceledException)
{
return AdminResult.Failure($"Tool-Job '{job.JobTypeId}' abgebrochen.");
}
catch (Exception ex)
{
return AdminResult.Failure($"Fehler beim Ausführen: {ex.Message}");
}
}
/// <summary>Stößt an, was hinter der Zeile steckt — Wakeup oder Tool-Job.</summary>
public Task<AdminResult> RunAsync(JobSummary job, CancellationToken ct)
=> job.Kind == JobKind.ToolJob
? RunToolJobAsync(job.AgentId, job.JobId, ct)
: RunAgentAsync(job.AgentId, job.Message, ct);
}
@@ -0,0 +1,97 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.App.Services;
/// <summary>Die Angaben, die einen neuen Dienst ausmachen.</summary>
public sealed record NewService(
string Name,
string Type,
int Port,
string Description = "");
/// <summary>
/// Dienste der Instanz anlegen, entfernen und umschalten.
///
/// <para>Stand bis zum 2026-09-06 in
/// <c>ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs</c>.</para>
///
/// <para><b>„Aktivieren" ist nicht „Starten".</b> Umgelegt wird das Kennzeichen
/// <c>Enabled</c> in der Instanzkonfiguration; ein laufender Dienstprozess wird davon
/// nicht angehalten. Eine Schaltfläche „Stop", nach der etwas weiterläuft, wäre
/// schlimmer als gar keine — deshalb heißen die Aufrufe hier so, wie sie wirken.</para>
/// </summary>
public sealed class ServiceAdministration(AppHost host)
{
public IReadOnlyList<ServiceConfig> All => host.Instance.Services;
public ServiceConfig? Find(string serviceId)
=> host.Instance.Services.FirstOrDefault(s => s.ServiceId == serviceId);
/// <summary>
/// Ergänzt die eingebauten Diensteinträge, die in dieser Instanz noch fehlen.
///
/// <para>Beim Aufbau der Aufgaben-Seite gerufen, damit eine frisch angelegte Instanz
/// nicht mit einer leeren Liste dasteht. Vorhandene Einträge bleiben unangetastet —
/// wer einen abgeschaltet hat, will ihn abgeschaltet haben.</para>
/// </summary>
public void EnsureBuiltIns()
{
var changed = false;
foreach (var builtIn in BuiltInServices.CreateDefaults())
{
if (host.Instance.Services.Any(s => s.ServiceId == builtIn.ServiceId))
continue;
host.Instance.Services.Insert(0, builtIn);
changed = true;
}
if (changed)
host.SaveInstance(new InstanceChange(InstanceChangeKind.Services));
}
public AdminResult Add(NewService request)
{
if (string.IsNullOrWhiteSpace(request.Name))
return AdminResult.Failure("Ohne Namen geht es nicht.");
var service = new ServiceConfig
{
Name = request.Name,
Type = request.Type,
Port = request.Port,
Enabled = true,
Description = request.Description
};
host.Instance.Services.Add(service);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Services, service.ServiceId));
return AdminResult.Success($"Dienst '{service.Name}' angelegt.");
}
public AdminResult Remove(string serviceId)
{
if (Find(serviceId) is not { } service)
return AdminResult.Failure($"Kein Dienst mit der Kennung '{serviceId}'.");
host.Instance.Services.Remove(service);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Services, serviceId));
return AdminResult.Success($"Dienst '{service.Name}' entfernt.");
}
public AdminResult SetEnabled(string serviceId, bool enabled)
{
if (Find(serviceId) is not { } service)
return AdminResult.Failure($"Kein Dienst mit der Kennung '{serviceId}'.");
service.Enabled = enabled;
host.SaveInstance(new InstanceChange(InstanceChangeKind.Services, serviceId));
return AdminResult.Success(enabled
? $"Dienst '{service.Name}' aktiviert."
: $"Dienst '{service.Name}' deaktiviert.");
}
}
@@ -28,9 +28,35 @@ public sealed class InstanceConfig
[JsonPropertyName("logDirectory")]
public string LogDirectory { get; set; } = "";
/// <summary>
/// Port der Weboberfläche. Je Instanz und nicht anwendungsweit: Zwei Instanzen auf
/// einer Maschine stritten sich sonst um einen Port.
/// </summary>
[JsonPropertyName("webServerPort")]
public int WebServerPort { get; set; } = 8080;
/// <summary>
/// Ob die Weboberfläche mitläuft. Ausgeschaltet vorbelegt — sie macht aus einer
/// lokalen Anwendung einen Netzwerkdienst, der Agenten mit Datei-, Mail- und
/// FTP-Zugriff steuert. Das schaltet man bewusst ein.
/// </summary>
[JsonPropertyName("webUiEnabled")]
public bool WebUiEnabled { get; set; }
/// <summary>
/// <c>loopback</c> (Vorgabe) oder <c>any</c>. Bei <c>any</c> ist
/// <see cref="WebUiToken"/> Pflicht, sonst startet die Weboberfläche nicht.
/// </summary>
[JsonPropertyName("webUiBinding")]
public string WebUiBinding { get; set; } = "loopback";
/// <summary>
/// Zugangstoken der Weboberfläche. Steht in <c>ConfigSecrets</c> und liegt damit
/// geschützt in der Instanzdatei — wie der OpenRouter-Schlüssel daneben.
/// </summary>
[JsonPropertyName("webUiToken")]
public string WebUiToken { get; set; } = "";
[JsonPropertyName("telegramClient")]
public TelegramClientConfig? TelegramClient { get; set; }
+5
View File
@@ -15,6 +15,11 @@ public sealed record ChatEntry(
public static class ChatSource
{
public const string WebView = "webview";
/// <summary>Die Weboberfläche im Browser — nicht zu verwechseln mit <see cref="WebView"/>,
/// das aus der WinForms-Zeit stammt und den eingebetteten Browser meinte.</summary>
public const string WebUi = "webui";
public const string Telegram = "telegram";
public const string AgentComm = "agentcomm";
public const string Job = "job";
@@ -22,7 +22,7 @@ public static class ConfigSecrets
{
"password", "password2fa", "passwort",
"apikey", "api_key", "xapikey", "openrouterapikey", "apihash", "apisecret",
"token", "accesstoken", "bottoken", "authtoken",
"token", "accesstoken", "bottoken", "authtoken", "webuitoken",
"secret", "clientsecret",
"connectionstring"
};
@@ -39,6 +39,12 @@ public static class ConfigSecrets
{
config.OpenRouterApiKey = transform(config.OpenRouterApiKey) ?? "";
// Das Zugangstoken der Weboberflaeche ist ein Zugangsdatum wie jedes andere.
// Ohne diese Zeile stuende es im Klartext neben dem geschuetzten
// OpenRouter-Schluessel — der Name allein in SecretKeys traegt hier nicht, weil
// InstanceConfig kein freies Woerterbuch ist, sondern feste Felder hat.
config.WebUiToken = transform(config.WebUiToken) ?? "";
if (config.TelegramClient is { } telegram)
{
telegram.ApiHash = transform(telegram.ApiHash) ?? "";
+37 -2
View File
@@ -10,6 +10,7 @@ using ClawdDotNet.App.Settings;
using ClawdDotNet.Desktop.Services;
using ClawdDotNet.Desktop.ViewModels;
using ClawdDotNet.Desktop.Views;
using ClawdDotNet.Web;
namespace ClawdDotNet.Desktop;
@@ -24,6 +25,9 @@ public partial class App : Application
private AppHost? _host;
/// <summary>Die Weboberflaeche, falls sie eingeschaltet ist. Sonst null.</summary>
private WebUiHost? _web;
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
private string? _preselectedInstancePath;
@@ -44,6 +48,10 @@ public partial class App : Application
desktop.ShutdownRequested += async (_, _) =>
{
// Reihenfolge: erst die Weboberflaeche, dann der Aufbau. Andersherum
// bediente Kestrel noch Anfragen gegen eine bereits geschlossene
// Datenbank.
await ShutdownWebAsync();
if (_host is not null) await _host.DisposeAsync();
};
@@ -110,6 +118,11 @@ public partial class App : Application
HookErrorReporting(_host);
HookLicenseWatch(_host, desktop);
// Nach dem Aufbau und vor dem Fenster: Die Weboberflaeche braucht den fertigen
// AppHost, und ein belegter Port darf das Fenster nicht aufhalten — TryStart
// gibt in jedem Fehlerfall null zurueck und schreibt den Grund ins Protokoll.
_web = WebUiHost.TryStart(_host, _host.LoggerFactory);
var mainViewModel = new MainWindowViewModel(_host);
// Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und
@@ -181,7 +194,7 @@ public partial class App : Application
/// der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu
/// erneuern, ist genau dann im Weg, wenn man sie braucht.</para>
/// </summary>
private static async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
private async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
{
if (host.Deploymentcenter is null) return;
@@ -221,12 +234,17 @@ public partial class App : Application
/// das tut es aber über <c>Environment.Exit</c> und übergeht damit Schritt 2
/// vollständig.</para>
/// </summary>
private static async Task StartUpdateAsync(
private async Task StartUpdateAsync(
AppHost host, IClassicDesktopStyleApplicationLifetime desktop, string version)
{
host.Deploymentcenter!.AnnounceUpdate(version);
var settings = host.Settings.AppSettings;
// Vor dem Aufbau: Kestrel darf keine Anfrage mehr gegen eine geschlossene
// Datenbank bedienen — und schon gar nicht, waehrend der update-agent
// gleich die Dateien darunter austauscht.
await ShutdownWebAsync();
await host.DisposeAsync();
var problem = host.Deploymentcenter.StartUpdate(settings, AppHost.AppVersion);
@@ -322,6 +340,8 @@ public partial class App : Application
if (path is null) return;
if (string.Equals(path, _host.InstancePath, StringComparison.OrdinalIgnoreCase)) return;
await ShutdownWebAsync();
var executable = Environment.ProcessPath;
if (executable is null)
@@ -353,6 +373,21 @@ public partial class App : Application
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
}
/// <summary>
/// Faehrt die Weboberflaeche herunter, falls sie laeuft, und merkt sich, dass sie
/// weg ist. Mehrfach aufrufbar: Update, Instanzwechsel und das gewoehnliche
/// Beenden nehmen denselben Weg, und der letzte von ihnen soll nicht ins Leere
/// greifen.
/// </summary>
private async Task ShutdownWebAsync()
{
if (_web is null) return;
var web = _web;
_web = null;
await web.DisposeAsync();
}
private static string? ReadInstanceArgument(IReadOnlyList<string>? args)
{
if (args is null) return null;
@@ -40,6 +40,13 @@
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
<!-- Die Weboberflaeche. Sie laeuft im selben Prozess und bekommt denselben AppHost;
warum das so sein muss, steht in ClawdDotNet.Web/WebUiHost.cs.
Die Richtung des Verweises ist Absicht: Desktop kennt Web, Web kennt Desktop
nicht. Wer hier umdreht, hat den Schnitt verletzt. -->
<ProjectReference Include="..\ClawdDotNet.Web\ClawdDotNet.Web.csproj" />
</ItemGroup>
<!-- Native Debug-Symbole aus NuGet-Paketen nicht mitkopieren. libSkiaSharp.pdb ist
@@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -77,12 +79,11 @@ public sealed partial class AgentItemViewModel : ObservableObject
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
public static readonly List<string> KnownTools =
[
"FileRW", "Telegram", "Mail", "Database", "FTP",
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
/// <summary>
/// Die Namensliste steht in der Anwendungsschicht — die Weboberfläche braucht
/// dieselbe, und zwei gepflegte Listen wären eine zu viel.
/// </summary>
public static IReadOnlyList<string> KnownTools => AgentAdministration.KnownTools;
public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary<string, string>? descriptions = null)
{
@@ -217,6 +218,18 @@ public sealed partial class AgentsPageViewModel : PageViewModel
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Agents");
ReloadAgents();
// Seit es eine zweite Oberflaeche gibt, ist diese Liste nicht mehr die
// einzige Quelle von Aenderungen: Ein im Browser angelegter Agent stuende
// sonst in der Datei und nicht im Fenster. Das Ereignis kommt auf dem Faden
// des Verursachers — deshalb ueber den Dispatcher.
//
// Kein Abmelden: Diese Seite lebt so lange wie die Anwendung.
host.InstanceChanged += change =>
{
if (change.Kind == InstanceChangeKind.Agents)
Dispatcher.UIThread.Post(ReloadAgents);
};
}
else
{
@@ -231,23 +244,34 @@ public sealed partial class AgentsPageViewModel : PageViewModel
/// Beschreibung eben leer.
/// </summary>
private IReadOnlyDictionary<string, string> ToolDescriptions()
=> _host?.Tools.GetAll()
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>();
=> _host?.AgentAdmin.ToolDescriptions() ?? new Dictionary<string, string>();
/// <summary>
/// Baut die Liste neu auf und behaelt die Auswahl, sofern es sie noch gibt.
///
/// <para>Die Auswahl zu behalten ist kein Feinschliff: Der Aufbau laeuft jetzt auch,
/// wenn die Weboberflaeche etwas geaendert hat. Waere die Auswahl danach jedesmal
/// weg, spraenge dem Benutzer der Cursor aus der Zeile, waehrend er arbeitet.</para>
/// </summary>
[RelayCommand]
private void ReloadAgents()
{
if (_host is null)
{
Agents.Clear();
return;
}
var previous = SelectedAgent?.AgentId;
Agents.Clear();
if (_host is null) return;
var descriptions = ToolDescriptions();
foreach (var agent in _host.Instance.Agents)
Agents.Add(new AgentItemViewModel(agent, descriptions));
SelectedAgent = Agents.FirstOrDefault();
SelectedAgent = Agents.FirstOrDefault(a => a.AgentId == previous) ?? Agents.FirstOrDefault();
}
partial void OnSelectedAgentChanged(AgentItemViewModel? value)
@@ -282,28 +306,22 @@ public sealed partial class AgentsPageViewModel : PageViewModel
var result = await RequestAddAgentDialog(vm);
if (result is null) return;
if (_host.Instance.Agents.Any(a => a.AgentId.Equals(result.AgentId, StringComparison.OrdinalIgnoreCase)))
{
StatusText = $"Ein Agent mit der ID '{result.AgentId}' existiert bereits.";
return;
}
// Erst die offenen Änderungen der Liste sichern, dann anlegen: Der Aufruf
// schreibt die Instanzdatei, und was hier noch nicht übernommen war, ginge sonst
// verloren.
ApplyPendingEdits();
var newConfig = new AgentConfig
{
AgentId = result.AgentId,
DisplayName = result.DisplayName,
Model = result.Model,
Description = result.Description
};
var outcome = _host.AgentAdmin.Add(
new NewAgent(result.AgentId, result.DisplayName, result.Model, result.Description));
_host.Instance.Agents.Add(newConfig);
SaveAgents();
StatusText = outcome.Message;
if (!outcome.Ok) return;
if (_host.AgentAdmin.Find(result.AgentId) is not { } newConfig) return;
var item = new AgentItemViewModel(newConfig, ToolDescriptions());
Agents.Add(item);
SelectedAgent = item;
StatusText = $"Agent '{result.DisplayName}' angelegt.";
}
[RelayCommand]
@@ -311,14 +329,15 @@ public sealed partial class AgentsPageViewModel : PageViewModel
{
if (_host is null || SelectedAgent is null) return;
var agentId = SelectedAgent.AgentId;
_host.Instance.Agents.RemoveAll(a => a.AgentId == agentId);
var removed = SelectedAgent;
ApplyPendingEdits();
SaveAgents();
Agents.Remove(SelectedAgent);
var outcome = _host.AgentAdmin.Remove(removed.AgentId);
StatusText = outcome.Message;
if (!outcome.Ok) return;
Agents.Remove(removed);
SelectedAgent = Agents.FirstOrDefault();
StatusText = $"Agent '{agentId}' entfernt.";
}
/// <summary>
@@ -338,23 +357,19 @@ public sealed partial class AgentsPageViewModel : PageViewModel
}
var config = SelectedAgent.Config;
var message = config.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
IsRunning = true;
StatusText = $"Führe '{config.DisplayName}' aus…";
try
{
var result = await _host.Engine.RunAsync(
config, message, _host.Instance.InstanceId, CancellationToken.None);
var outcome = await _host.Runs.RunAgentAsync(
config.AgentId, config.Scheduler?.TaskMessage, CancellationToken.None);
StatusText = $"'{config.DisplayName}' abgeschlossen: {result.Status} "
+ $"({result.StepCount} Schritte, {result.TokensUsed:N0} Tokens).";
}
catch (Exception ex)
{
StatusText = $"Lauf fehlgeschlagen: {ex.Message}";
_logger?.LogError(ex, "Handlauf des Agenten fehlgeschlagen");
StatusText = outcome.Message;
if (!outcome.Ok)
_logger?.LogWarning("Handlauf des Agenten fehlgeschlagen: {Grund}", outcome.Message);
}
finally
{
@@ -420,19 +435,19 @@ public sealed partial class AgentsPageViewModel : PageViewModel
[RelayCommand]
private void SaveToolSettings()
{
if (SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
if (_host is null || SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
ToolSettings.ConfirmCommand.Execute(null);
if (ToolSettings.Result is not { } config) return;
SelectedAgent.Config.Tools[SelectedTool.Name] = config;
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen.
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen — das Ankreuzfeld
// zieht nach, damit Liste und Konfiguration nicht auseinanderlaufen.
SelectedTool.IsEnabled = true;
SaveAgents();
StatusText = $"Einstellungen für '{SelectedTool.Name}' gespeichert.";
ApplyPendingEdits();
StatusText = _host.AgentAdmin
.SetToolSettings(SelectedAgent.AgentId, SelectedTool.Name, config).Message;
}
[RelayCommand]
@@ -440,14 +455,27 @@ public sealed partial class AgentsPageViewModel : PageViewModel
{
if (_host is null) return;
foreach (var agentVm in Agents)
agentVm.ApplyChanges();
ApplyPendingEdits();
_host.AgentAdmin.Save();
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
_logger?.LogInformation("Agenten-Konfiguration gespeichert.");
StatusText = "Agenten-Konfiguration gespeichert.";
}
/// <summary>
/// Überträgt, was in den Feldern der Seite steht, in die Konfigurationsobjekte.
///
/// <para>Die Ansichtsmodelle halten eigene Eigenschaften und schreiben sie erst bei
/// <c>ApplyChanges</c> zurück. Jeder Aufruf, der gleich die Instanzdatei schreibt,
/// muss vorher hier durch — sonst verschwindet eine gerade getippte Änderung, weil
/// jemand nebenbei einen Agenten angelegt hat.</para>
/// </summary>
private void ApplyPendingEdits()
{
foreach (var agentVm in Agents)
agentVm.ApplyChanges();
}
// ─── Teilerstellungen ───
//
// Die drei Teiler der Seite sollen den Neustart ueberleben. Gespeichert wird ein
@@ -3,10 +3,8 @@ using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Models;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClawdDotNet.Core.Scheduling;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
@@ -49,9 +47,7 @@ public sealed record ServiceDisplayItemViewModel(
public sealed partial class TasksPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly JobHistoryService? _jobHistoryService;
private readonly ILogger? _logger;
private readonly Dictionary<string, DateTime> _jobLastRunTimes = new();
public ObservableCollection<JobDisplayItemViewModel> JobEntries { get; } = [];
public ObservableCollection<ServiceDisplayItemViewModel> ServiceEntries { get; } = [];
@@ -96,13 +92,33 @@ public sealed partial class TasksPageViewModel : PageViewModel
if (host is not null)
{
_jobHistoryService = new JobHistoryService(host.InstancePath);
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks");
EnsureBuiltInServices();
host.ServiceAdmin.EnsureBuiltIns();
RefreshJobList();
RefreshServiceList();
RefreshJobHistory();
// Auch die Weboberflaeche legt Jobs und Dienste an. Das Ereignis kommt auf
// dem Faden des Verursachers, deshalb ueber den Dispatcher.
//
// Kein Abmelden: Diese Seite lebt so lange wie die Anwendung.
host.InstanceChanged += change => Dispatcher.UIThread.Post(() =>
{
switch (change.Kind)
{
case InstanceChangeKind.Jobs:
case InstanceChangeKind.Agents:
// Auch bei Agenten: Verschwindet ein Agent, verschwinden seine
// geplanten Laeufe mit ihm.
RefreshJobList();
break;
case InstanceChangeKind.Services:
RefreshServiceList();
break;
}
});
}
else
{
@@ -110,93 +126,57 @@ public sealed partial class TasksPageViewModel : PageViewModel
}
}
private void SaveInstanceConfig()
{
if (_host is null) return;
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
}
private void EnsureBuiltInServices()
{
if (_host is null) return;
var defaults = BuiltInServices.CreateDefaults();
var changed = false;
foreach (var def in defaults)
{
if (_host.Instance.Services.Any(s => s.ServiceId == def.ServiceId))
continue;
_host.Instance.Services.Insert(0, def);
changed = true;
}
if (changed)
SaveInstanceConfig();
}
[RelayCommand]
private void RefreshJobList()
{
var previous = SelectedJob?.JobId;
JobEntries.Clear();
if (_host is null) return;
foreach (var agent in _host.Instance.Agents)
{
// Agent Wakeup Jobs
if (agent.Scheduler is not null)
{
var nextRun = CalculateNextRun(agent.Scheduler.Cron);
var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime)
? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
foreach (var job in _host.JobAdmin.List())
JobEntries.Add(ToDisplayItem(job, _host.Runs.LastRun(job.JobId)));
JobEntries.Add(new JobDisplayItemViewModel(
"Agent Wakeup",
$"agent_{agent.AgentId}",
agent.AgentId,
agent.DisplayName,
"—",
agent.Scheduler.Cron,
agent.Scheduler.TaskMessage,
agent.Scheduler.RunOnStart,
nextRun,
agentLastRun,
"—",
_host.Scanner is not null ? "Aktiv" : "Inaktiv"
));
}
SelectedJob = JobEntries.FirstOrDefault(j => j.JobId == previous);
}
// Tool Jobs
foreach (var toolJob in agent.ToolJobs)
{
var nextRun = CalculateNextRun(toolJob.Cron);
var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime)
? lastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
/// <summary>
/// Formt einen geplanten Lauf für die Anzeige um.
///
/// <para>Die Fachschicht liefert Zeitpunkte als <see cref="DateTime"/>; wie sie
/// aussehen und was an ihrer Stelle steht, wenn es keinen gibt, entscheidet die
/// Anzeige. Deshalb steht das Gedankenstrich-Zeichen hier und nicht dort.</para>
/// </summary>
private static JobDisplayItemViewModel ToDisplayItem(JobSummary job, DateTime? lastRun)
{
var isToolJob = job.Kind == JobKind.ToolJob;
JobEntries.Add(new JobDisplayItemViewModel(
"Tool Job",
toolJob.JobId,
agent.AgentId,
agent.DisplayName,
toolJob.ToolName,
toolJob.Cron,
toolJob.JobTypeId,
toolJob.RunOnStart,
nextRun,
toolLastRun,
"—",
toolJob.Enabled ? "Aktiv" : "Deaktiviert"
));
}
}
var nextRun = !job.CronValid
? "Ungültig"
: job.NextRunUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
return new JobDisplayItemViewModel(
isToolJob ? "Tool Job" : "Agent Wakeup",
job.JobId,
job.AgentId,
job.AgentName,
isToolJob ? job.ToolName : "—",
job.Cron,
job.Message,
job.RunOnStart,
nextRun,
lastRun?.ToString("yyyy-MM-dd HH:mm:ss") ?? "—",
"—",
job.Enabled
? "Aktiv"
: isToolJob ? "Deaktiviert" : "Inaktiv");
}
[RelayCommand]
private void RefreshServiceList()
{
var previous = SelectedService?.ServiceId;
ServiceEntries.Clear();
if (_host is null) return;
@@ -211,15 +191,17 @@ public sealed partial class TasksPageViewModel : PageViewModel
svc.Description
));
}
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == previous);
}
[RelayCommand]
private void RefreshJobHistory()
{
JobHistoryEntries.Clear();
if (_jobHistoryService is null) return;
if (_host is null) return;
foreach (var entry in _jobHistoryService.GetAll().Take(200))
foreach (var entry in _host.Runs.History.GetAll().Take(200))
JobHistoryEntries.Add(entry);
}
@@ -232,40 +214,27 @@ public sealed partial class TasksPageViewModel : PageViewModel
var result = await RequestAddJobDialog(vm);
if (result is null) return;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == result.AgentId);
if (agent is null) return;
var outcome = _host.JobAdmin.Add(ToDefinition(result));
StatusText = outcome.Message;
if (result.IsToolJob)
{
agent.ToolJobs.Add(new ToolJobConfig
{
ToolName = result.ToolName,
JobTypeId = result.JobTypeId,
Cron = result.CronExpression,
RunOnStart = result.RunOnStart,
Enabled = true
});
if (outcome.Ok)
_logger?.LogInformation("Geplanter Lauf hinzugefügt: Agent={Agent}, Cron={Cron}",
result.AgentId, result.CronExpression);
_logger?.LogInformation("Tool-Job hinzugefügt: Agent={Agent}, Tool={Tool}, Cron={Cron}",
agent.DisplayName, result.ToolName, result.CronExpression);
}
else
{
agent.Scheduler = new SchedulerConfig
{
Cron = result.CronExpression,
RunOnStart = result.RunOnStart,
TaskMessage = result.TaskMessage
};
_logger?.LogInformation("Agent-Wakeup-Job hinzugefügt: Agent={Agent}, Cron={Cron}",
agent.DisplayName, result.CronExpression);
}
SaveInstanceConfig();
RefreshJobList();
}
/// <summary>Übersetzt das Ergebnis des Dialogs in das, womit die Fachschicht arbeitet.</summary>
private static JobDefinition ToDefinition(AddJobResultData result)
=> new(
result.AgentId,
result.IsToolJob ? JobKind.ToolJob : JobKind.AgentWakeup,
result.ToolName,
result.JobTypeId,
result.CronExpression,
result.TaskMessage,
result.RunOnStart);
[RelayCommand]
private async Task EditJobAsync()
{
@@ -290,28 +259,7 @@ public sealed partial class TasksPageViewModel : PageViewModel
var result = await RequestAddJobDialog(vm);
if (result is null) return;
if (isToolJob)
{
var toolJob = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (toolJob is not null)
{
toolJob.Cron = result.CronExpression;
toolJob.RunOnStart = result.RunOnStart;
toolJob.JobTypeId = result.JobTypeId;
toolJob.ToolName = result.ToolName;
}
}
else
{
if (agent.Scheduler is not null)
{
agent.Scheduler.Cron = result.CronExpression;
agent.Scheduler.TaskMessage = result.TaskMessage;
agent.Scheduler.RunOnStart = result.RunOnStart;
}
}
SaveInstanceConfig();
StatusText = _host.JobAdmin.Update(entry.JobId, ToDefinition(result)).Message;
RefreshJobList();
}
@@ -321,19 +269,12 @@ public sealed partial class TasksPageViewModel : PageViewModel
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
if (entry.JobType == "Tool Job")
{
agent.ToolJobs.RemoveAll(j => j.JobId == entry.JobId);
}
else
{
agent.Scheduler = null;
}
StatusText = _host.JobAdmin.Remove(
entry.AgentId,
entry.JobId,
entry.JobType == "Tool Job" ? JobKind.ToolJob : JobKind.AgentWakeup).Message;
SaveInstanceConfig();
RefreshJobList();
}
@@ -343,77 +284,25 @@ public sealed partial class TasksPageViewModel : PageViewModel
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
var isToolJob = entry.JobType == "Tool Job";
IsRunningJob = true;
StatusText = $"Führe Job für '{agent.DisplayName}' aus…";
StatusText = $"Führe Job für '{entry.AgentName}' aus…";
try
{
if (entry.JobType == "Tool Job")
{
if (_host.Scanner is null)
{
StatusText = "Scanner ist nicht aktiv.";
return;
}
var outcome = isToolJob
? await _host.Runs.RunToolJobAsync(entry.AgentId, entry.JobId, CancellationToken.None)
: await _host.Runs.RunAgentAsync(entry.AgentId, entry.TaskMessage, CancellationToken.None);
var jobConfig = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (jobConfig is null) return;
StatusText = outcome.Message;
var ran = await _host.Scanner.RunTaskNowAsync(
$"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None);
_jobLastRunTimes[jobConfig.JobId] = DateTime.Now;
var info = ran ? "Ausgeführt" : "Nicht bereit";
_jobHistoryService?.Add(new JobHistoryEntry
{
JobName = jobConfig.JobTypeId,
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = $"Tool Job: {jobConfig.ToolName}",
Info = info,
Status = "Manual"
});
StatusText = $"Tool Job '{jobConfig.JobTypeId}' abgeschlossen ({info}).";
}
else
{
if (_host.Engine is null)
{
StatusText = "Agent-Engine ist nicht aktiv (kein API-Key).";
return;
}
var taskMessage = agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
var result = await _host.Engine.RunAsync(agent, taskMessage, _host.Instance.InstanceId, CancellationToken.None);
_jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now;
_jobHistoryService?.Add(new JobHistoryEntry
{
JobName = "Agent Wakeup",
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = taskMessage,
Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)",
Status = "Manual"
});
StatusText = $"Agent '{agent.DisplayName}' abgeschlossen: {result.Status} ({result.TokensUsed:N0} Tokens).";
}
if (!outcome.Ok)
_logger?.LogWarning("Job-Ausführung fehlgeschlagen: {Grund}", outcome.Message);
RefreshJobList();
RefreshJobHistory();
}
catch (Exception ex)
{
StatusText = $"Fehler beim Ausführen: {ex.Message}";
_logger?.LogError(ex, "Job-Ausführung fehlgeschlagen");
}
finally
{
IsRunningJob = false;
@@ -429,17 +318,12 @@ public sealed partial class TasksPageViewModel : PageViewModel
var result = await RequestAddServiceDialog(vm);
if (result is null) return;
var svcConfig = new ServiceConfig
{
Name = result.ServiceName,
Type = result.ServiceType,
Port = result.ServicePort,
Enabled = true,
Description = result.ServiceDescription
};
StatusText = _host.ServiceAdmin.Add(new NewService(
result.ServiceName,
result.ServiceType,
result.ServicePort,
result.ServiceDescription)).Message;
_host.Instance.Services.Add(svcConfig);
SaveInstanceConfig();
RefreshServiceList();
}
@@ -448,8 +332,7 @@ public sealed partial class TasksPageViewModel : PageViewModel
{
if (_host is null || SelectedService is null) return;
_host.Instance.Services.RemoveAll(s => s.ServiceId == SelectedService.ServiceId);
SaveInstanceConfig();
StatusText = _host.ServiceAdmin.Remove(SelectedService.ServiceId).Message;
RefreshServiceList();
}
@@ -469,28 +352,14 @@ public sealed partial class TasksPageViewModel : PageViewModel
var entry = SelectedJob;
if (entry.JobType != "Tool Job")
{
StatusText = "Nur Tool-Jobs lassen sich abschalten. "
+ "Ein Agent-Wakeup wird über die Agenten-Seite entfernt.";
return;
}
StatusText = _host.JobAdmin
.SetEnabled(entry.AgentId, entry.JobId, enabled: entry.Status != "Aktiv").Message;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
var job = agent?.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (job is null) return;
job.Enabled = !job.Enabled;
SaveInstanceConfig();
RefreshJobList();
// Die Auswahl geht beim Neuaufbau der Liste verloren — sie wieder zu setzen
// erspart es, für zwei Schaltvorgänge zweimal dieselbe Zeile anzuklicken.
SelectedJob = JobEntries.FirstOrDefault(j => j.JobId == entry.JobId);
StatusText = job.Enabled
? $"Job '{entry.TaskMessage}' aktiviert."
: $"Job '{entry.TaskMessage}' deaktiviert.";
}
/// <summary>
@@ -507,18 +376,12 @@ public sealed partial class TasksPageViewModel : PageViewModel
if (_host is null || SelectedService is null) return;
var id = SelectedService.ServiceId;
var service = _host.Instance.Services.FirstOrDefault(s => s.ServiceId == id);
if (service is null) return;
service.Enabled = !service.Enabled;
SaveInstanceConfig();
StatusText = _host.ServiceAdmin
.SetEnabled(id, enabled: SelectedService.Status != "Bereit").Message;
RefreshServiceList();
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == id);
StatusText = service.Enabled
? $"Dienst '{service.Name}' aktiviert."
: $"Dienst '{service.Name}' deaktiviert.";
}
[RelayCommand]
@@ -532,18 +395,4 @@ public sealed partial class TasksPageViewModel : PageViewModel
};
}
private static string CalculateNextRun(string? cronExpr)
{
if (string.IsNullOrWhiteSpace(cronExpr)) return "—";
try
{
var cron = CronExpression.Parse(cronExpr);
var next = cron.GetNextOccurrence(DateTime.UtcNow);
return next?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
}
catch
{
return "Ungültig";
}
}
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Web</RootNamespace>
</PropertyGroup>
<!-- Bewusst Microsoft.NET.Sdk und nicht Microsoft.NET.Sdk.Web: Dieses Projekt ist eine
Bibliothek, die in einem fremden Prozess einen Kestrel hochfaehrt. Das Web-SDK
braechte einen eigenen Einstiegspunkt und den Manifest-Mechanismus fuer statische
Ressourcen mit - beides waere hier im Weg, weil veroeffentlicht wird, was
ClawdDotNet.Desktop veroeffentlicht.
ASP.NET Core kommt als Framework-Referenz und damit ohne NuGet-Paket. Das ist auch
der Grund, warum NuGet.Config unveraendert bleibt. -->
<ItemGroup>
<InternalsVisibleTo Include="ClawdDotNet.Web.Tests" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Die Weboberflaeche als gewoehnliche Dateien neben der Programmdatei, nicht
eingebettet. Zwei Gruende: Wer am HTML arbeitet, sieht seine Aenderung nach einem
Neuladen im Browser statt nach einem Neubau - und eingebettete Ressourcen tragen
den Ordnernamen als Punkt im Namen, was bei jedem Bindestrich und jedem
Unterverzeichnis eine eigene kleine Falle ist.
Content mit CopyToOutputDirectory wandert ueber die Projektreferenz in die Ausgabe
von ClawdDotNet.Desktop und von dort ins Paket. Geprueft mit einem echten
dotnet publish, nicht angenommen. -->
<ItemGroup>
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
</ItemGroup>
</Project>
+177
View File
@@ -0,0 +1,177 @@
namespace ClawdDotNet.Web.Contracts;
// ─────────────────────────────────────────────────────────────────────────────
// Die Übertragungstypen der Weboberfläche.
//
// Warum es sie gibt, statt die Kerntypen zu serialisieren:
//
// 1. AgentConfig trägt Zugangsdaten. Ein `return agent;` hätte den
// Werkzeug-Konfigurationsbaum samt Passwörtern ins Netz geschickt — die Sorte
// Fehler, die niemandem auffällt, weil alles funktioniert.
// 2. Die Kerntypen ändern sich mit dem Kern. Ein neues Feld in TaskItem wäre sonst
// still eine API-Änderung, auf die sich jemand verlassen hat.
// 3. Die Grenze soll einen Prozesswechsel überstehen (siehe WebUiHost). Über eine
// Prozessgrenze geht nur, was hier steht.
//
// Namen auf Englisch in camelCase wie im übrigen Code; die Beschreibungen auf Deutsch.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>Antwort auf einen verwaltenden Aufruf — dieselbe Form wie <c>AdminResult</c>.</summary>
public sealed record OutcomeDto(bool Ok, string Message);
/// <summary>Version, Bau und Instanz. Was auf der Info-Seite steht.</summary>
public sealed record InfoDto(
string AppVersion,
string BuildSummary,
string InstanceId,
string InstanceName,
bool EngineActive,
bool SchedulerActive,
bool StagingActive,
bool DeploymentcenterActive);
/// <summary>Erreichbarkeit und Guthaben bei OpenRouter.</summary>
public sealed record StatusDto(
bool ApiReachable,
string StatusText,
string CreditsText,
double? CreditBalance,
double? CreditRemaining);
/// <summary>Ein Agent, ohne alles, was ein Zugangsdatum sein könnte.</summary>
public sealed record AgentDto(
string AgentId,
string DisplayName,
string Model,
string Description,
string PromptCaching,
int MaxToolResultChars,
int MaxSteps,
int MaxContextTokens,
int TimeoutSeconds,
decimal DailyCostUsd,
long DailyTokens,
bool IsRunning,
IReadOnlyList<AgentToolDto> Tools);
/// <summary>
/// Ein Werkzeug am Agenten. <b>Ohne die Einstellungen</b> — die stehen in einem freien
/// Wörterbuch und enthalten regelmäßig Passwörter und Schlüssel. Was einstellbar ist und
/// ob etwas gesetzt ist, sagt <see cref="ToolSettingsDto"/>.
/// </summary>
public sealed record AgentToolDto(string Name, string Description, bool Enabled);
/// <summary>
/// Die Einstellungen eines Werkzeugs, maskiert.
///
/// <para>Ein Wert, dessen Name ein Zugangsdatum bezeichnet, kommt nie heraus. Statt
/// seiner steht in <see cref="Secrets"/>, ob überhaupt etwas gesetzt ist — mehr braucht
/// eine Anzeige nicht, und mehr darf sie nicht wissen.</para>
/// </summary>
public sealed record ToolSettingsDto(
string Name,
IReadOnlyDictionary<string, object?> Values,
IReadOnlyDictionary<string, bool> Secrets);
/// <summary>Eine Zeile im Chatverlauf.</summary>
public sealed record ChatEntryDto(
string Role,
string Content,
DateTime Timestamp,
string? Source);
/// <summary>Was der Browser schickt, wenn jemand etwas in den Chat tippt.</summary>
public sealed record SendMessageDto(string Message);
/// <summary>Ein geplanter Lauf.</summary>
public sealed record JobDto(
string Kind,
string JobId,
string AgentId,
string AgentName,
string ToolName,
string Cron,
string Message,
bool RunOnStart,
DateTime? NextRunUtc,
DateTime? LastRunLocal,
bool CronValid,
bool Enabled);
/// <summary>Was einen geplanten Lauf ausmacht — beim Anlegen wie beim Ändern.</summary>
public sealed record JobInputDto(
string AgentId,
string Kind,
string ToolName = "",
string JobTypeId = "",
string Cron = "",
string TaskMessage = "",
bool RunOnStart = false);
/// <summary>Ein Dienst der Instanz.</summary>
public sealed record ServiceDto(
string ServiceId,
string Name,
string Type,
int Port,
bool Enabled,
bool BuiltIn,
string Description);
/// <summary>Was einen neuen Dienst ausmacht.</summary>
public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = "");
/// <summary>Ein neuer Agent.</summary>
public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "");
/// <summary>Eine Aufgabe vom Taskboard.</summary>
public sealed record TaskDto(
string Id,
string Title,
string Status,
string Type,
int Priority,
string Assignee,
bool RequireApproval,
string Acceptance,
IReadOnlyList<string> BlockedBy,
string Body,
DateTime CreatedAt,
DateTime UpdatedAt);
/// <summary>Ein eingefrorener Aufruf, der auf Freigabe wartet.</summary>
public sealed record StagedCallDto(
long Id,
string RunId,
string AgentId,
string Tool,
string? Action,
string Proposal,
string Status,
string ArgumentsJson);
/// <summary>Warum ein Vorschlag abgelehnt wurde.</summary>
public sealed record RejectDto(string Reason);
/// <summary>
/// Ein Lauf mit dem, was er gekostet hat.
///
/// <para><see cref="CostIsKnown"/> ist nicht schmückendes Beiwerk: Ohne geladene
/// Preisliste steht in <see cref="CostUsd"/> eine Null, die „kostenlos" heißen könnte
/// und „unbekannt" heißt. Eine Anzeige, die das nicht unterscheidet, lügt.</para>
/// </summary>
public sealed record UsageDto(
long Id,
string AgentId,
string Model,
int PromptTokens,
int CompletionTokens,
int CachedTokens,
decimal CostUsd,
bool CostIsKnown,
string Status,
int StepCount,
DateTime OccurredAt);
/// <summary>Eine Protokollzeile.</summary>
public sealed record LogLineDto(string Module, string Level, string Text);
+156
View File
@@ -0,0 +1,156 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Web.Contracts;
/// <summary>
/// Die eine Stelle, an der Kerntypen zu Übertragungstypen werden.
///
/// <para>Eine Stelle, weil die Maskierung von Zugangsdaten hier hängt. Verstreut über
/// zehn Endpunkte wäre die Frage „kommt hier ein Passwort heraus?" nicht mehr zu
/// beantworten, sondern nur noch zu hoffen.</para>
/// </summary>
internal static class Mapping
{
public static AgentDto ToDto(
this AgentConfig agent,
IReadOnlyDictionary<string, string> toolDescriptions,
bool isRunning)
=> new(
agent.AgentId,
agent.DisplayName,
agent.Model,
agent.Description,
agent.PromptCaching,
agent.MaxToolResultChars,
agent.LoopGuard.MaxSteps,
agent.LoopGuard.MaxContextTokens,
agent.LoopGuard.TimeoutSeconds,
agent.Budget.DailyCostUsd,
agent.Budget.DailyTokens,
isRunning,
AgentAdministration.KnownTools
.Select(name => new AgentToolDto(
name,
toolDescriptions.GetValueOrDefault(name) ?? "",
agent.Tools.ContainsKey(name)))
.ToList());
/// <summary>
/// Die Einstellungen eines Werkzeugs — <b>ohne die Geheimnisse</b>.
///
/// <para>Welcher Schlüssel eines ist, entscheidet <see cref="ConfigSecrets"/>: der
/// Kern weiß es bereits, und eine zweite Liste hier wäre eine, die beim nächsten
/// neuen Werkzeug vergessen wird. Statt des Werts steht in <c>Secrets</c>, ob
/// überhaupt einer gesetzt ist.</para>
/// </summary>
public static ToolSettingsDto ToDto(
string toolName, IReadOnlyDictionary<string, object?> settings)
{
var values = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
var secrets = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in settings)
{
if (ConfigSecrets.IsSecretKey(key))
secrets[key] = value is not null && value is not string { Length: 0 };
else
values[key] = value;
}
return new ToolSettingsDto(toolName, values, secrets);
}
public static ChatEntryDto ToDto(this ChatEntry entry)
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
public static JobDto ToDto(this JobSummary job, DateTime? lastRun)
=> new(
job.Kind == JobKind.ToolJob ? "toolJob" : "agentWakeup",
job.JobId,
job.AgentId,
job.AgentName,
job.ToolName,
job.Cron,
job.Message,
job.RunOnStart,
job.NextRunUtc,
lastRun,
job.CronValid,
job.Enabled);
public static JobDefinition ToDefinition(this JobInputDto input)
=> new(
input.AgentId,
string.Equals(input.Kind, "toolJob", StringComparison.OrdinalIgnoreCase)
? JobKind.ToolJob
: JobKind.AgentWakeup,
input.ToolName,
input.JobTypeId,
input.Cron,
input.TaskMessage,
input.RunOnStart);
public static ServiceDto ToDto(this ServiceConfig service)
=> new(
service.ServiceId,
service.Name,
service.Type,
service.Port,
service.Enabled,
service.BuiltIn,
service.Description);
public static TaskDto ToDto(this TaskItem task)
=> new(
task.Id,
task.Title,
task.Status.ToString(),
task.Type.ToString(),
task.Priority,
task.Assignee,
task.RequireApproval,
task.Acceptance,
task.BlockedBy,
task.Body,
task.CreatedAt,
task.UpdatedAt);
/// <summary>
/// Ein wartender Aufruf samt seiner eingefrorenen Argumente.
///
/// <para>Die Argumente gehen <b>roh</b> heraus, und das ist der ganze Sinn der
/// Freigabe: Wer entscheidet, muss sehen, was genau ausgeführt würde — nicht eine
/// Zusammenfassung davon. Sie sind zugleich Fremdtext aus einem Modell; die Anzeige
/// setzt sie als Text ein, nie als HTML.</para>
/// </summary>
public static StagedCallDto ToDto(this StagedCall call)
=> new(
call.Id,
call.RunId,
call.AgentId,
call.Tool,
call.Action,
call.Proposal,
call.Status.ToString(),
call.ArgumentsJson);
public static UsageDto ToDto(this RunUsage usage)
=> new(
usage.Id,
usage.AgentId,
usage.Model,
usage.PromptTokens,
usage.CompletionTokens,
usage.CachedTokens,
usage.CostUsd,
usage.CostIsKnown,
usage.Status,
usage.StepCount,
usage.OccurredAt);
}
@@ -0,0 +1,340 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Die HTTP-Fläche der Weboberfläche.
///
/// <para>Jeder Endpunkt hier ist dünn: Er nimmt entgegen, ruft einen Dienst aus
/// <c>ClawdDotNet.App</c> und formt das Ergebnis um. <b>Eine Regel, die nur hier steht,
/// ist eine an der falschen Stelle</b> — sie gehört in die Anwendungsschicht, wo die
/// Avalonia-Oberfläche sie auch findet. Wer beim Bauen eines Endpunkts merkt, dass er
/// etwas entscheiden muss, hat einen Befund und keine Aufgabe.</para>
/// </summary>
internal static class ApiEndpoints
{
public static void MapApi(this WebApplication app)
{
var api = app.MapGroup("/api");
api.MapInfo();
api.MapAgents();
api.MapChat();
api.MapJobs();
api.MapServices();
api.MapTasks();
api.MapStaging();
api.MapUsage();
api.MapLogs();
app.MapEvents();
}
/// <summary>
/// Übersetzt ein <see cref="AdminResult"/> in eine Antwort.
///
/// <para>400 und nicht 500, wenn es nicht geklappt hat: Eine doppelte Agenten-ID ist
/// keine Störung des Servers, sondern eine Antwort auf eine Frage. Die Begründung
/// steht im Rumpf und ist dieselbe, die im Fenster stünde.</para>
/// </summary>
public static IResult ToResponse(this AdminResult result)
=> result.Ok
? Results.Ok(new OutcomeDto(true, result.Message))
: Results.BadRequest(new OutcomeDto(false, result.Message));
/// <summary>
/// Für Bereiche, die es ohne Zutun nicht gibt: keine Engine ohne API-Schlüssel, kein
/// Scanner ohne gemeinsamen Arbeitsbereich, kein Staging ohne beides.
///
/// <para>503 mit einer Begründung statt einer <c>NullReferenceException</c>. Die
/// Oberfläche kann daraus einen Satz machen; aus einem Absturz nicht.</para>
/// </summary>
public static IResult Unavailable(string reason)
=> Results.Json(new OutcomeDto(false, reason), statusCode: StatusCodes.Status503ServiceUnavailable);
// ─── Info und Zustand ───
private static void MapInfo(this RouteGroupBuilder api)
{
api.MapGet("/info", (AppHost host) => new InfoDto(
AppHost.AppVersion,
AppHost.BuildSummary,
host.Instance.InstanceId,
host.Instance.InstanceName,
EngineActive: host.Engine is not null,
SchedulerActive: host.Scanner is not null,
StagingActive: host.Staging is not null,
DeploymentcenterActive: host.Deploymentcenter is not null));
api.MapGet("/status", (AppHost host) =>
{
if (host.Status is not { } status)
return Unavailable("Kein OpenRouter-Schlüssel hinterlegt.");
return Results.Ok(new StatusDto(
status.IsApiReachable,
status.StatusText,
status.CreditsText,
status.CreditBalance,
status.CreditRemaining));
});
}
// ─── Agenten ───
private static void MapAgents(this RouteGroupBuilder api)
{
var agents = api.MapGroup("/agents");
agents.MapGet("/", (AppHost host) =>
{
var descriptions = host.AgentAdmin.ToolDescriptions();
return host.AgentAdmin.All
.Select(a => a.ToDto(descriptions, host.Engine?.IsRunning(a.AgentId) ?? false))
.ToList();
});
agents.MapGet("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Find(agentId) is { } agent
? Results.Ok(agent.ToDto(
host.AgentAdmin.ToolDescriptions(),
host.Engine?.IsRunning(agent.AgentId) ?? false))
: Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'.")));
agents.MapPost("/", (AppHost host, NewAgentDto input) =>
host.AgentAdmin.Add(new NewAgent(
input.AgentId, input.DisplayName, input.Model, input.Description)).ToResponse());
agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Remove(agentId).ToResponse());
agents.MapPut("/{agentId}/tools/{toolName}", (
AppHost host, string agentId, string toolName, bool enabled) =>
host.AgentAdmin.SetToolEnabled(agentId, toolName, enabled).ToResponse());
agents.MapGet("/{agentId}/tools/{toolName}/settings", (
AppHost host, string agentId, string toolName) =>
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'."));
var settings = agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>();
return Results.Ok(Mapping.ToDto(toolName, settings));
});
agents.MapPost("/{agentId}/run", async (
AppHost host, string agentId, CancellationToken ct) =>
(await host.Runs.RunAgentAsync(agentId, message: null, ct)).ToResponse());
}
// ─── Chat ───
private static void MapChat(this RouteGroupBuilder api)
{
var chat = api.MapGroup("/agents/{agentId}/chat");
chat.MapGet("/", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
return Results.Ok(engine.GetChatHistory(agentId).Select(e => e.ToDto()).ToList());
});
chat.MapPost("/", async (
AppHost host, string agentId, SendMessageDto input, CancellationToken ct) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
if (host.AgentAdmin.Find(agentId) is not { } agent)
return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'."));
var text = input.Message?.Trim() ?? "";
if (text.Length == 0)
return Results.BadRequest(new OutcomeDto(false, "Leere Nachricht."));
// Quelle mitgeben: OnChatEntryAdded reicht sie weiter, und das Fenster kann
// anzeigen, dass die Nachricht aus dem Browser kam. Die Engine reiht Laeufe
// desselben Agenten ohnehin hintereinander ein — zwei Oberflaechen
// gleichzeitig sind deshalb kein Sonderfall.
var result = await engine.ChatAsync(
agent, text, host.Instance.InstanceId, ct, source: Core.Engine.ChatSource.WebUi);
return Results.Ok(new OutcomeDto(
true, $"{agent.DisplayName} fertig ({result.Status}, {result.TokensUsed:N0} Tokens)."));
});
chat.MapPost("/abort", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
engine.AbortChat(agentId);
return Results.Ok(new OutcomeDto(true, "Chat abgebrochen."));
});
chat.MapDelete("/", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
engine.ClearChatHistory(agentId);
return Results.Ok(new OutcomeDto(true, "Verlauf geleert."));
});
}
// ─── Geplante Läufe ───
private static void MapJobs(this RouteGroupBuilder api)
{
var jobs = api.MapGroup("/jobs");
jobs.MapGet("/", (AppHost host) => host.JobAdmin.List()
.Select(j => j.ToDto(host.Runs.LastRun(j.JobId)))
.ToList());
jobs.MapPost("/", (AppHost host, JobInputDto input) =>
host.JobAdmin.Add(input.ToDefinition()).ToResponse());
jobs.MapPut("/{jobId}", (AppHost host, string jobId, JobInputDto input) =>
host.JobAdmin.Update(jobId, input.ToDefinition()).ToResponse());
jobs.MapDelete("/{jobId}", (AppHost host, string jobId, string agentId, string kind) =>
host.JobAdmin.Remove(
agentId,
jobId,
string.Equals(kind, "toolJob", StringComparison.OrdinalIgnoreCase)
? JobKind.ToolJob
: JobKind.AgentWakeup).ToResponse());
jobs.MapPut("/{jobId}/enabled", (
AppHost host, string jobId, string agentId, bool enabled) =>
host.JobAdmin.SetEnabled(agentId, jobId, enabled).ToResponse());
jobs.MapPost("/{jobId}/run", async (
AppHost host, string jobId, CancellationToken ct) =>
{
if (host.JobAdmin.List().FirstOrDefault(j => j.JobId == jobId) is not { } job)
return Results.NotFound(new OutcomeDto(false, $"Kein geplanter Lauf '{jobId}'."));
return (await host.Runs.RunAsync(job, ct)).ToResponse();
});
}
// ─── Dienste ───
private static void MapServices(this RouteGroupBuilder api)
{
var services = api.MapGroup("/services");
services.MapGet("/", (AppHost host) =>
host.ServiceAdmin.All.Select(s => s.ToDto()).ToList());
services.MapPost("/", (AppHost host, ServiceInputDto input) =>
host.ServiceAdmin.Add(new NewService(
input.Name, input.Type, input.Port, input.Description)).ToResponse());
services.MapDelete("/{serviceId}", (AppHost host, string serviceId) =>
host.ServiceAdmin.Remove(serviceId).ToResponse());
services.MapPut("/{serviceId}/enabled", (
AppHost host, string serviceId, bool enabled) =>
host.ServiceAdmin.SetEnabled(serviceId, enabled).ToResponse());
}
// ─── Taskboard ───
private static void MapTasks(this RouteGroupBuilder api)
{
var tasks = api.MapGroup("/tasks");
tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) =>
{
if (host.Board is not { } board)
return Unavailable(
"Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.");
var items = await board.ListAsync(
new Core.Tasks.TaskQuery { Limit = Math.Clamp(limit, 1, 500) }, ct);
return Results.Ok(items.Select(t => t.ToDto()).ToList());
});
tasks.MapGet("/{id}", async (AppHost host, string id, CancellationToken ct) =>
{
if (host.Board is not { } board)
return Unavailable("Kein Taskboard in dieser Instanz.");
return await board.GetAsync(id, ct) is { } task
? Results.Ok(task.ToDto())
: Results.NotFound(new OutcomeDto(false, $"Keine Aufgabe '{id}'."));
});
}
// ─── Freigaben ───
private static void MapStaging(this RouteGroupBuilder api)
{
var staging = api.MapGroup("/staging");
staging.MapGet("/", async (AppHost host, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var pending = await service.ListPendingAsync(ct);
return Results.Ok(pending.Select(c => c.ToDto()).ToList());
});
staging.MapPost("/{id:long}/approve", async (AppHost host, long id, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var result = await service.ApproveAsync(id, decidedBy: "webui", ct);
return Results.Ok(new OutcomeDto(
result.Kind is Core.Staging.StagingResultKind.Executed, result.Message));
});
staging.MapPost("/{id:long}/reject", async (
AppHost host, long id, RejectDto input, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var result = await service.RejectAsync(id, "webui", input.Reason ?? "", ct);
return Results.Ok(new OutcomeDto(
result.Kind is Core.Staging.StagingResultKind.Rejected, result.Message));
});
}
// ─── Verbrauch ───
private static void MapUsage(this RouteGroupBuilder api)
{
api.MapGet("/usage", async (AppHost host, CancellationToken ct, int limit = 100) =>
{
if (host.Usage is not { } usage)
return Unavailable("Keine Verbrauchsdaten: Agenten sind nicht aktiv.");
var recent = await usage.GetRecentAsync(Math.Clamp(limit, 1, 1000), ct);
return Results.Ok(recent.Select(r => r.ToDto()).ToList());
});
}
// ─── Protokoll ───
private static void MapLogs(this RouteGroupBuilder api)
{
api.MapGet("/logs/modules", (AppHost host) =>
new LogTail(host.LogDirectory).AvailableModules());
}
}
@@ -0,0 +1,231 @@
using System.Text.Json;
using System.Threading.Channels;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Der Ereignisstrom: was im Kern passiert, während niemand danach fragt.
///
/// <para><b>Server-Sent Events und nicht WebSocket.</b> Die Richtung ist einseitig —
/// Befehle gehen über gewöhnliche POSTs —, SSE übersteht Zwischenschichten, bringt das
/// Wiederverbinden mit und braucht auf keiner Seite eine zusätzliche Bibliothek.</para>
///
/// <para><b>Die Ereignisse kommen aus Hintergrundfäden.</b> <c>AgentEngine</c> löst sie
/// dort aus, wo der Lauf gerade ist. Deshalb geht hier nichts direkt in die Antwort,
/// sondern erst in einen Kanal, den die Anfrage abarbeitet: Ein Ereignisbehandler, der in
/// den Antwortstrom schreibt, träfe früher oder später auf einen zweiten, der dasselbe
/// tut, und ein <c>HttpResponse</c> verträgt keine zwei Schreiber.</para>
/// </summary>
internal static class EventEndpoints
{
/// <summary>
/// Wie viele Ereignisse ein langsamer Browser hinter sich herziehen darf, bevor die
/// ältesten wegfallen.
///
/// <para>Begrenzt und verwerfend, nicht unbegrenzt: Ein Tab, der im Hintergrund
/// eingefroren ist, würde sonst den Speicher des Prozesses füllen, in dem die
/// Agenten laufen. Ein verlorener Zwischenstand ist der bessere Verlust — die Seite
/// lädt den Verlauf beim Verbinden ohnehin neu.</para>
/// </summary>
private const int QueueDepth = 256;
public static void MapEvents(this WebApplication app)
{
var events = app.MapGroup("/events");
events.MapGet("/", StreamCoreEvents);
events.MapGet("/logs", StreamLogs);
}
private static async Task StreamCoreEvents(
HttpContext context, AppHost host, CancellationToken ct)
{
var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true
});
void Publish(string type, object payload)
=> queue.Writer.TryWrite(Format(type, payload));
// Die Behandler stehen als benannte Delegaten da, weil sie im finally wieder
// abgemeldet werden muessen. Ein Lambda direkt am += liesse sich nicht mehr
// entfernen, und nach dreissig Neuladen der Seite liefe jede Nachricht
// dreissigmal durch.
void OnChat(string agentId, string role, string content, string? source)
=> Publish("chat", new { agentId, role, content, source, timestamp = DateTime.UtcNow });
void OnStep(string agentId, string text)
=> Publish("step", new { agentId, text });
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
=> Publish("run", new
{
model,
status = result.Status.ToString(),
steps = result.StepCount,
tokens = result.TokensUsed
});
void OnInstanceChanged(InstanceChange change)
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
void OnStatus()
=> Publish("status", new { });
if (host.Engine is { } engine)
{
engine.OnChatEntryAdded += OnChat;
engine.OnStepCompleted += OnStep;
engine.OnRunCompleted += OnRunCompleted;
}
host.InstanceChanged += OnInstanceChanged;
if (host.Status is { } status)
status.OnStatusUpdated += OnStatus;
try
{
await WriteStream(context, queue.Reader, ct);
}
finally
{
if (host.Engine is { } e)
{
e.OnChatEntryAdded -= OnChat;
e.OnStepCompleted -= OnStep;
e.OnRunCompleted -= OnRunCompleted;
}
host.InstanceChanged -= OnInstanceChanged;
if (host.Status is { } s)
s.OnStatusUpdated -= OnStatus;
queue.Writer.TryComplete();
}
}
/// <summary>
/// Das Protokoll, fortlaufend.
///
/// <para><b>Eine eigene <see cref="LogTail"/> je Verbindung</b>, und das ist keine
/// Sparsamkeitsfrage: <c>ReadNew</c> liefert die Zeilen seit dem letzten Aufruf und
/// merkt sich die Stelle. Eine geteilte Ausprägung hieße, dass der erste Browser die
/// Zeilen bekommt und alle anderen nichts.</para>
/// </summary>
private static async Task StreamLogs(
HttpContext context, AppHost host, CancellationToken ct, string? module = null, string? level = null)
{
var tail = new LogTail(host.LogDirectory);
var filter = Enum.TryParse<LogLevelFilter>(level, ignoreCase: true, out var parsed)
? parsed
: LogLevelFilter.All;
var interval = TimeSpan.FromMilliseconds(
Math.Clamp(host.Settings.AppSettings.LogRefreshIntervalMs, 200, 5000));
PrepareResponse(context);
using var timer = new PeriodicTimer(interval);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
var lines = tail.ReadNew(module, filter);
if (lines.Count == 0)
{
// Ein Kommentar haelt die Verbindung offen, ohne ein Ereignis
// vorzutaeuschen. Ohne ihn kappt so mancher Proxy nach einer Minute.
await context.Response.WriteAsync(": still\n\n", ct);
}
else
{
foreach (var line in lines)
await context.Response.WriteAsync(
Format("log", new LogLineDto(line.Module, line.Level, line.Text)), ct);
}
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt. Der einzige normale Weg, auf dem dieser Strom endet.
}
}
private static async Task WriteStream(
HttpContext context, ChannelReader<string> reader, CancellationToken ct)
{
PrepareResponse(context);
// Sofort etwas schicken: Erst damit steht die Verbindung fuer den Browser, und
// onopen feuert. Ohne das haengt die Seite im Ungewissen, bis das erste Ereignis
// von selbst kommt — was Stunden dauern kann.
await context.Response.WriteAsync(": bereit\n\n", ct);
await context.Response.Body.FlushAsync(ct);
var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
try
{
var next = reader.ReadAsync(ct).AsTask();
var beat = heartbeat.WaitForNextTickAsync(ct).AsTask();
while (true)
{
var done = await Task.WhenAny(next, beat);
if (done == next)
{
await context.Response.WriteAsync(await next, ct);
next = reader.ReadAsync(ct).AsTask();
}
else
{
if (!await beat) break;
await context.Response.WriteAsync(": still\n\n", ct);
beat = heartbeat.WaitForNextTickAsync(ct).AsTask();
}
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt.
}
catch (ChannelClosedException)
{
// Der Strom wird abgeraeumt.
}
finally
{
heartbeat.Dispose();
}
}
private static void PrepareResponse(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
// Ohne das puffert ein vorgelagerter nginx den Strom und liefert ihn erst, wenn
// er voll ist — aus einem Ereignisstrom wird dann eine Ereignissammlung.
context.Response.Headers["X-Accel-Buffering"] = "no";
}
private static string Format(string type, object payload)
=> $"event: {type}\ndata: {JsonSerializer.Serialize(payload)}\n\n";
}
+75
View File
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Web.Security;
/// <summary>
/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
///
/// <para><b>Was ohne Token ausgeliefert wird, ist die leere Hülle</b> — HTML, CSS und
/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert:
/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die
/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte.
/// In den Dateien steht nichts, was jemand nicht wissen dürfte — die Daten kommen
/// ausschließlich über <c>/api</c> und <c>/events</c>, und die sind gesperrt.</para>
///
/// <para><b>Warum der Vergleich in fester Zeit läuft:</b> Ein gewöhnlicher
/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst,
/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der
/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand.</para>
/// </summary>
public sealed class TokenGate(byte[] expected)
{
public static TokenGate For(string token)
=> new(Encoding.UTF8.GetBytes(token));
/// <summary>
/// Ob dieser Pfad ein Token braucht. Alles unterhalb von <c>/api</c> und
/// <c>/events</c>, sonst nichts.
/// </summary>
public static bool NeedsToken(PathString path)
=> path.StartsWithSegments("/api") || path.StartsWithSegments("/events");
/// <summary>
/// Holt das Token aus der Anfrage.
///
/// <para><c>Authorization: Bearer …</c> ist der Weg. Ein Cookie wäre bequemer und
/// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit,
/// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die
/// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier
/// keine eigene Baustelle.</para>
///
/// <para>Die Ausnahme ist der Ereignisstrom: <c>EventSource</c> im Browser kann keine
/// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der
/// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit
/// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in
/// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse
/// Proxy davor und entscheidet dort, was protokolliert wird.</para>
/// </summary>
public static string? ReadToken(HttpRequest request)
{
var header = request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..].Trim();
if (request.Path.StartsWithSegments("/events")
&& request.Query.TryGetValue("token", out var fromQuery))
return fromQuery.ToString();
return null;
}
public bool Matches(string? candidate)
{
if (string.IsNullOrEmpty(candidate)) return false;
// FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass
// dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis,
// sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit
// verglichen.
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(candidate), expected);
}
}
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Web;
/// <summary>
/// Reicht die Protokollierung des Webhosts an die Protokollierung der Anwendung durch.
///
/// <para>Ohne diese Zwischenschicht gäbe es zwei Möglichkeiten, und beide wären falsch:
/// Entweder der Webhost bekommt eigene Ausgabewege — dann stünde die Hälfte der
/// Meldungen woanders als die andere. Oder man reicht ihm die <c>ILoggerFactory</c> der
/// Anwendung direkt — dann räumt er sie beim Herunterfahren mit ab, und der Rest der
/// Anwendung protokolliert ab da ins Leere.</para>
///
/// <para>Deshalb: durchreichen, und beim Abräumen <b>nichts</b> tun. Die Fabrik gehört
/// dem <c>AppHost</c>, und der räumt sie selbst ab.</para>
/// </summary>
internal sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => shared.CreateLogger(categoryName);
public void Dispose()
{
// Absicht. Siehe Klassenbeschreibung.
}
}
+224
View File
@@ -0,0 +1,224 @@
using ClawdDotNet.App;
using ClawdDotNet.Web.Endpoints;
using ClawdDotNet.Web.Security;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Web;
/// <summary>
/// Die Weboberfläche, im Prozess der Anwendung.
///
/// <para><b>Im selben Prozess, und das ist keine Bequemlichkeit.</b> Die
/// <c>AgentEngine</c> hält Chatverlauf und Kontext je Agent im Arbeitsspeicher und
/// serialisiert Läufe über Semaphore je Agent; <c>SqliteStorage</c> serialisiert
/// Schreibvorgänge prozessweit. Ein zweiter Prozess hätte eine zweite Engine und keine
/// gemeinsame Sperre — das Ergebnis wäre keine Verlangsamung, sondern eine ungültige
/// <c>tool_call</c>-Sequenz und HTTP 400 mitten im Lauf.</para>
///
/// <para>Die Trennung „Kern als Dienst, Oberfläche als Client" steht auf der Roadmap als
/// offene Entscheidung. Diese Klasse trifft sie nicht — aber die HTTP-Fläche darunter
/// ist so geschnitten, dass sie einen Prozesswechsel überstünde: keine Objektidentität
/// über Aufrufe hinweg, alles über Kennungen und eigene Übertragungstypen.</para>
/// </summary>
public sealed class WebUiHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly ILogger _logger;
private WebUiHost(WebApplication app, ILogger logger, string url)
{
_app = app;
_logger = logger;
Url = url;
}
/// <summary>Die Adresse, unter der die Oberfläche hört.</summary>
public string Url { get; }
/// <summary>
/// Fährt die Weboberfläche hoch, sofern sie eingeschaltet und richtig eingestellt
/// ist.
///
/// <para><b>Gibt in jedem Fehlerfall <c>null</c> zurück und wirft nicht.</b> Eine
/// Anwendung, deren Fenster nicht aufgeht, weil ein Webserver keine Adresse bekommen
/// hat, wäre genau dann im Weg, wenn man sie braucht. Was schiefging, steht im
/// Protokoll.</para>
/// </summary>
public static WebUiHost? TryStart(AppHost host, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Web");
var options = WebUiOptions.TryCreate(host.Instance, out var problem);
if (options is null)
{
if (problem is not null)
logger.LogWarning("Weboberfläche nicht gestartet: {Grund}", problem);
return null;
}
try
{
var app = Build(host, loggerFactory, options);
// Nicht abwarten: StartAsync bindet den Port und kehrt zurueck; blockierend
// wuerde hier der Faden haengen, auf dem gleich das Fenster entsteht.
app.StartAsync().GetAwaiter().GetResult();
logger.LogInformation(
"Weboberfläche hört auf {Url} ({Bindung})",
options.DisplayUrl,
options.LoopbackOnly ? "nur lokal" : "alle Adressen");
return new WebUiHost(app, logger, options.DisplayUrl);
}
catch (Exception ex)
{
// Der haeufigste Fall ist ein belegter Port - eine zweite Instanz, ein
// vergessener Prozess. Das ist ein Grund, ohne Weboberflaeche zu laufen, und
// keiner, die Anwendung nicht zu starten.
logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", options.Url);
return null;
}
}
private static WebApplication Build(
AppHost host, ILoggerFactory loggerFactory, WebUiOptions options)
{
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
ContentRootPath = AppContext.BaseDirectory,
EnvironmentName = Environments.Production,
ApplicationName = typeof(WebUiHost).Assembly.GetName().Name
});
// Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen.
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
builder.WebHost.UseUrls(options.Url);
builder.Services.AddSingleton(host);
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(TokenGate.For(options.Token));
var app = builder.Build();
// Reihenfolge ist hier die halbe Miete:
// 1. Fehlerbehandlung ganz aussen, damit sie auch das Folgende einfaengt.
// 2. Torwaechter vor allem, was Daten liefert.
// 3. Statische Dateien - die leere Huelle, bewusst ohne Token.
// 4. Endpunkte.
app.Use(ReportUnhandled(host, loggerFactory));
app.Use(CheckToken);
MapStaticFiles(app);
app.MapApi();
return app;
}
/// <summary>
/// Fängt ab, was in einem Endpunkt durchrutscht: meldet es an den Fehler-Stream und
/// antwortet mit 500.
///
/// <para>Dasselbe, was <c>App.axaml.cs</c> für die Oberfläche tut — nur von innen.
/// Ohne das nähme eine Ausnahme in einem Endpunkt im ungünstigen Fall den ganzen
/// Prozess mit, und mit ihm die Agenten.</para>
/// </summary>
private static Func<HttpContext, RequestDelegate, Task> ReportUnhandled(
AppHost host, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Web");
return async (context, next) =>
{
try
{
await next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// Der Browser hat aufgelegt. Das ist kein Fehler und schon gar keiner,
// der ins Deploymentcenter gehoert.
}
catch (Exception ex)
{
logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path);
_ = host.Errors.ReportAsync(ex, fatal: false);
if (context.Response.HasStarted)
return;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { fehler = "Interner Fehler." });
}
};
}
private static async Task CheckToken(HttpContext context, RequestDelegate next)
{
if (!TokenGate.NeedsToken(context.Request.Path))
{
await next(context);
return;
}
var gate = context.RequestServices.GetRequiredService<TokenGate>();
if (!gate.Matches(TokenGate.ReadToken(context.Request)))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." });
return;
}
await next(context);
}
/// <summary>
/// Liefert die Oberfläche aus <c>wwwroot</c> neben der Programmdatei.
///
/// <para>Fehlt der Ordner, läuft die API trotzdem — nützlich, solange an der
/// Oberfläche noch gebaut wird, und ehrlicher als ein Absturz beim Start.</para>
/// </summary>
private static void MapStaticFiles(WebApplication app)
{
var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(webRoot))
{
app.Logger.LogWarning(
"Kein wwwroot unter {Pfad} — es läuft nur die API.", webRoot);
return;
}
var files = new PhysicalFileProvider(webRoot);
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
}
public async ValueTask DisposeAsync()
{
try
{
// Erst hier herunterfahren, dann der AppHost: Andersherum bediente Kestrel
// noch Anfragen gegen eine bereits geschlossene Datenbank.
await _app.StopAsync(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden");
}
await _app.DisposeAsync();
}
}
+75
View File
@@ -0,0 +1,75 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Web;
/// <summary>
/// Die geprüften Startangaben der Weboberfläche.
///
/// <para>Getrennt von <see cref="InstanceConfig"/>, weil dort Wünsche stehen und hier
/// Entscheidungen: Der Port ist eine Zahl, die Bindung eine von zweien, und ein Token,
/// das nicht taugt, ist gar keins. Was hier nicht durchkommt, kommt gar nicht erst an
/// den Kestrel.</para>
/// </summary>
public sealed record WebUiOptions(string Url, string Token, bool LoopbackOnly)
{
/// <summary>Kürzer als das hier darf ein Token nicht sein.</summary>
public const int MinimumTokenLength = 20;
/// <summary>
/// Prüft die Angaben der Instanz.
///
/// <para>Gibt <c>null</c> zurück, wenn die Weboberfläche abgeschaltet ist — das ist
/// kein Fehler und die Vorbelegung. <paramref name="problem"/> trägt den Grund,
/// wenn sie eingeschaltet ist, aber nicht starten darf.</para>
///
/// <para><b>Die harte Regel steht hier:</b> Bindung an alle Adressen ohne Token wird
/// abgelehnt, nicht bloß bemängelt. Ein Netzwerkdienst, der Agenten mit Datei-,
/// Mail- und FTP-Zugriff steuert, ist nichts, was versehentlich offen stehen darf.
/// Auch auf Loopback gilt Token-Pflicht: Auf einer Maschine, auf der Agenten
/// Browser-Werkzeuge bedienen, ist „localhost" keine Vertrauensgrenze.</para>
/// </summary>
public static WebUiOptions? TryCreate(InstanceConfig instance, out string? problem)
{
problem = null;
if (!instance.WebUiEnabled)
return null;
var loopbackOnly = !string.Equals(
instance.WebUiBinding, "any", StringComparison.OrdinalIgnoreCase);
var port = instance.WebServerPort;
if (port is < 1 or > 65535)
{
problem = $"Der Port {port} ist keiner. Erwartet wird 1 bis 65535.";
return null;
}
var token = instance.WebUiToken?.Trim() ?? "";
if (token.Length == 0)
{
problem = loopbackOnly
? "Die Weboberfläche braucht ein Zugangstoken (webUiToken in der Instanzdatei)."
: "Die Weboberfläche ist auf alle Adressen gebunden und hat kein Zugangstoken. "
+ "So wird sie nicht gestartet.";
return null;
}
if (token.Length < MinimumTokenLength)
{
problem = $"Das Zugangstoken ist zu kurz — mindestens {MinimumTokenLength} Zeichen.";
return null;
}
var host = loopbackOnly ? "127.0.0.1" : "0.0.0.0";
return new WebUiOptions($"http://{host}:{port}", token, loopbackOnly);
}
/// <summary>
/// Die Adresse, die man einem Menschen zeigt. <c>0.0.0.0</c> ist keine, die sich
/// anklicken lässt.
/// </summary>
public string DisplayUrl => LoopbackOnly ? Url : Url.Replace("0.0.0.0", Environment.MachineName);
}
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
// ClawdDotNet — WebUI Haupteinstiegspunkt
//
// Initialisiert Authentifizierung, Router, Ereignisstrom, Farbschema und Kopfzeile.
import { api, hatToken, loescheToken, beiUnberechtigt } from "./modules/api.js";
import { verbindeStrom, trenneStrom, beiVerbindungsStatus, abonnieren } from "./modules/events.js";
import { initialisiereTheme, wechsleTheme } from "./modules/theme.js";
import { registriereRoute, initialisiereRouter, navigiere } from "./modules/router.js";
import { initialisiereLogin, zeigeAnmeldung, versteckeAnmeldung } from "./views/login.js";
// Ansichten
import { rendereChat } from "./views/chat.js";
import { rendereAgenten } from "./views/agents.js";
import { rendereAufgaben } from "./views/tasks.js";
import { rendereDienste } from "./views/services.js";
import { rendereStaging, holeWartendeAnzahl } from "./views/staging.js";
import { rendereVerbrauch } from "./views/usage.js";
import { rendereProtokoll, trenneLogStream } from "./views/logs.js";
import { rendereInfo } from "./views/info.js";
import { rendereSicherung, rendereEinstellungen, rendereAgentenChats } from "./views/placeholders.js";
// ─── 1. Routen registrieren ───
registriereRoute("/chat", () => { trenneLogStream(); return rendereChat(); });
registriereRoute("/chat/:agentId", (p) => { trenneLogStream(); return rendereChat(p); });
registriereRoute("/agenten", () => { trenneLogStream(); return rendereAgenten(); });
registriereRoute("/agenten/:agentId", (p) => { trenneLogStream(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { trenneLogStream(); return rendereAufgaben(); });
registriereRoute("/dienste", () => { trenneLogStream(); return rendereDienste(); });
registriereRoute("/staging", () => { trenneLogStream(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { trenneLogStream(); return rendereVerbrauch(); });
registriereRoute("/agenten-chats", () => { trenneLogStream(); return rendereAgentenChats(); });
registriereRoute("/protokoll", () => rendereProtokoll());
registriereRoute("/sicherung", () => { trenneLogStream(); return rendereSicherung(); });
registriereRoute("/einstellungen", () => { trenneLogStream(); return rendereEinstellungen(); });
registriereRoute("/info", () => { trenneLogStream(); return rendereInfo(); });
// ─── 2. DOM-Verdrahtung ───
const seitenleiste = document.getElementById("seitenleiste");
const sidebarToggle = document.getElementById("sidebar-toggle");
const mobilMenueBtn = document.getElementById("mobil-menue-btn");
const seitenleisteBackdrop = document.getElementById("seitenleiste-backdrop");
const themeSchalter = document.getElementById("theme-schalter");
const abmeldenBtn = document.getElementById("abmelden-btn");
const verbindungPunkt = document.getElementById("verbindung-punkt");
const kopfInstanz = document.getElementById("kopf-instanz");
const kopfOpenRouter = document.getElementById("kopf-openrouter-status");
// Seitenleiste kollabieren (Desktop)
sidebarToggle?.addEventListener("click", () => {
seitenleiste?.classList.toggle("kollabiert");
});
// Mobil-Menü öffnen
mobilMenueBtn?.addEventListener("click", () => {
seitenleiste?.classList.add("mobil-offen");
if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = false;
});
// Mobil-Menü schließen bei Klick auf Backdrop oder Link
function schliesseMobilMenue() {
seitenleiste?.classList.remove("mobil-offen");
if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = true;
}
seitenleisteBackdrop?.addEventListener("click", schliesseMobilMenue);
document.querySelectorAll(".nav-link").forEach((link) => {
link.addEventListener("click", () => {
if (window.innerWidth <= 768) schliesseMobilMenue();
});
});
// Farbschema umschalten
themeSchalter?.addEventListener("click", () => {
wechsleTheme();
});
// Abmelden
abmeldenBtn?.addEventListener("click", () => {
loescheToken();
trenneStrom();
trenneLogStream();
zeigeAnmeldung();
});
// Bei unberechtigtem API-Aufruf (401) sofort zur Anmeldung springen
beiUnberechtigt(() => {
trenneStrom();
trenneLogStream();
zeigeAnmeldung();
});
// Verbindungsstatus des Ereignisstroms
beiVerbindungsStatus((istOffen) => {
if (!verbindungPunkt) return;
if (istOffen) {
verbindungPunkt.classList.add("offen");
verbindungPunkt.title = "Ereignisstrom: verbunden";
} else {
verbindungPunkt.classList.remove("offen");
verbindungPunkt.title = "Ereignisstrom: getrennt";
}
});
// ─── 3. Start & Initialisierung ───
initialisiereTheme();
initialisiereLogin(starteHauptAnwendung);
async function aktualisiereKopfStatus() {
try {
const status = await api("/status");
if (kopfOpenRouter && status) {
kopfOpenRouter.textContent = status.creditsText
? `OpenRouter: ${status.creditsText}`
: (status.isApiReachable ? "OpenRouter: OK" : "OpenRouter: offline");
}
} catch {
if (kopfOpenRouter) kopfOpenRouter.textContent = "";
}
}
async function aktualisiereStagingZaehler() {
const anzahl = await holeWartendeAnzahl();
const badge = document.getElementById("staging-badge");
if (badge) {
if (anzahl > 0) {
badge.textContent = String(anzahl);
badge.hidden = false;
} else {
badge.hidden = true;
}
}
}
async function starteHauptAnwendung(info) {
versteckeAnmeldung();
if (kopfInstanz && info) {
kopfInstanz.textContent = `${info.instanceName} (${info.instanceId})`;
}
// Ereignisstrom verbinden
verbindeStrom();
// Status & Zähler laden
await aktualisiereKopfStatus();
await aktualisiereStagingZaehler();
// Ereignisse für Kopf-Updates
abonnieren("status", () => {
aktualisiereKopfStatus();
});
abonnieren("instance", (daten) => {
if (daten.kind === "Settings") {
aktualisiereKopfStatus();
}
});
// Router initialisieren
initialisiereRouter();
}
// Prüfe bestehende Session
if (hatToken()) {
api("/info")
.then((info) => {
starteHauptAnwendung(info);
})
.catch(() => {
loescheToken();
zeigeAnmeldung();
});
} else {
zeigeAnmeldung();
}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ClawdDotNet</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<!-- ─── Anmelde-Ansicht ─── -->
<div id="anmeldung">
<div class="karte anmelde-kasten">
<div class="karte-kopf">
<h2>ClawdDotNet</h2>
</div>
<p class="hinweis">
Bitte gib das Zugangstoken der Instanz ein (<code>webUiToken</code> in der Instanzdatei).
</p>
<form id="anmeldeformular" class="formular-raster">
<input type="password" id="token-eingabe" placeholder="Zugangstoken (mind. 20 Zeichen)" autocomplete="current-password" required>
<button type="submit" class="primaer">Anmelden</button>
<p id="anmeldefehler" class="fehler" hidden></p>
</form>
</div>
</div>
<!-- ─── Hauptanwendung ─── -->
<div id="anwendung" hidden>
<div id="seitenleiste-backdrop" class="seitenleiste-backdrop" hidden></div>
<!-- Seitenleiste / Navigation -->
<aside id="seitenleiste" class="seitenleiste">
<div class="seitenleiste-kopf">
<span class="marke">ClawdDotNet</span>
<button id="sidebar-toggle" class="schaltflaeche-icon flach" title="Seitenleiste ein-/ausklappen" type="button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
</div>
<nav class="seitenleiste-inhalt">
<div class="nav-gruppe">
<div class="nav-gruppe-titel">Arbeiten</div>
<a href="#/chat" class="nav-link" title="Chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
<span class="nav-label">Chat</span>
</a>
<a href="#/agenten" class="nav-link" title="Agenten">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="nav-label">Agenten</span>
</a>
<a href="#/aufgaben" class="nav-link" title="Aufgaben">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span class="nav-label">Aufgaben</span>
</a>
<a href="#/dienste" class="nav-link" title="Dienste">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M4 14a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6z M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z M12 6v6 M8 8h.01 M16 8h.01"/>
</svg>
<span class="nav-label">Dienste</span>
</a>
</div>
<div class="nav-gruppe">
<div class="nav-gruppe-titel">Analyse</div>
<a href="#/verbrauch" class="nav-link" title="Tokennutzung">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7"/>
</svg>
<span class="nav-label">Tokennutzung</span>
</a>
<a href="#/agenten-chats" class="nav-link" title="Agenten-Chats">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M8 12h.01M12 12h.01M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z"/>
</svg>
<span class="nav-label">Agenten-Chats</span>
</a>
</div>
<div class="nav-gruppe">
<div class="nav-gruppe-titel">System</div>
<a href="#/staging" class="nav-link" title="Freigaben">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4"/>
</svg>
<span class="nav-label">Freigaben</span>
<span id="staging-badge" class="nav-badge" hidden>0</span>
</a>
<a href="#/protokoll" class="nav-link" title="Protokoll">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6"/>
</svg>
<span class="nav-label">Protokoll</span>
</a>
<a href="#/sicherung" class="nav-link" title="Sicherung">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8"/>
</svg>
<span class="nav-label">Sicherung</span>
</a>
<a href="#/einstellungen" class="nav-link" title="Einstellungen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="nav-label">Einstellungen</span>
</a>
<a href="#/info" class="nav-link" title="Info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
<span class="nav-label">Info</span>
</a>
</div>
</nav>
</aside>
<!-- Hauptinhalt -->
<div class="hauptbereich">
<header class="kopfzeile">
<div class="kopfzeile-links">
<button id="mobil-menue-btn" class="schaltflaeche-icon flach" title="Menü öffnen" type="button">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<span id="kopf-instanz" class="gedaempft"></span>
</div>
<div class="kopfzeile-rechts">
<span id="kopf-openrouter-status" class="gedaempft text-klein"></span>
<span id="verbindung-punkt" class="status-punkt" title="Ereignisstrom: getrennt"></span>
<button id="theme-schalter" class="schaltflaeche-icon flach" title="Farbschema umschalten" type="button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</button>
<button id="abmelden-btn" class="schaltflaeche flach klein" title="Abmelden" type="button">
Abmelden
</button>
</div>
</header>
<main class="inhalt-scroll">
<div id="ansicht-inhalt"></div>
</main>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
// ClawdDotNet — API-Zugriffsschicht
//
// Verwaltet das Zugangstoken im sessionStorage und kapselt fetch-Aufrufe.
export const TOKEN_SCHLUESSEL = "clawd.token";
let token = sessionStorage.getItem(TOKEN_SCHLUESSEL) ?? "";
const unberechtigtCallbacks = new Set();
/**
* Gibt das aktuell gespeicherte Token zurück.
* @returns {string}
*/
export function holeToken() {
return token;
}
/**
* Speichert ein neues Token im Speicher und sessionStorage.
* @param {string} neuesToken
*/
export function setzeToken(neuesToken) {
token = (neuesToken || "").trim();
if (token) {
sessionStorage.setItem(TOKEN_SCHLUESSEL, token);
} else {
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
}
}
/**
* Löscht das Token (Abmeldung).
*/
export function loescheToken() {
token = "";
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
}
/**
* Prüft, ob ein Token im Speicher liegt.
* @returns {boolean}
*/
export function hatToken() {
return Boolean(token && token.length > 0);
}
/**
* Registriert einen Rückruf für den Fall einer 401-Ablehnung (sofortiger Login-Sprung).
* @param {() => void} callback
*/
export function beiUnberechtigt(callback) {
unberechtigtCallbacks.add(callback);
return () => unberechtigtCallbacks.delete(callback);
}
function meldeUnberechtigt() {
loescheToken();
for (const cb of unberechtigtCallbacks) {
try {
cb();
} catch (err) {
console.error("Fehler im Unberechtigt-Callback:", err);
}
}
}
/**
* Führt einen Aufruf gegen die API aus.
* @param {string} pfad Pfad relativ zu /api, z.B. "/info" oder "/agents"
* @param {RequestInit} [optionen={}]
* @returns {Promise<any>}
*/
export async function api(pfad, optionen = {}) {
const kopfzeilen = {
...(optionen.headers ?? {}),
Authorization: `Bearer ${token}`
};
if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) {
kopfzeilen["Content-Type"] = "application/json";
optionen.body = JSON.stringify(optionen.body);
}
const antwort = await fetch(`/api${pfad}`, {
...optionen,
headers: kopfzeilen
});
if (antwort.status === 401) {
meldeUnberechtigt();
const fehler = new Error("Kein gültiges Token. Bitte neu anmelden.");
fehler.abgelehnt = true;
fehler.status = 401;
throw fehler;
}
if (!antwort.ok) {
let meldung = `HTTP ${antwort.status}`;
try {
const rumpf = await antwort.json();
meldung = rumpf?.message ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
} catch {
// Wenn kein JSON geliefert wird, bleibt es beim HTTP-Code
}
const fehler = new Error(meldung);
fehler.status = antwort.status;
throw fehler;
}
if (antwort.status === 204) {
return null;
}
return antwort.json();
}
+280
View File
@@ -0,0 +1,280 @@
// ClawdDotNet — Sichere DOM-Helferfunktionen
//
// Leitfaden-Regel 3.1: Fremdtext ist nie HTML. Kein innerHTML im gesamten Baum.
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* Erzeugt ein HTML-Element sicher ohne innerHTML.
* @param {string} tag
* @param {object} [eigenschaften]
* @param {...(Node|string|null|undefined)} kinder
* @returns {HTMLElement}
*/
export function erstelleElement(tag, eigenschaften = {}, ...kinder) {
const el = document.createElement(tag);
for (const [schluessel, wert] of Object.entries(eigenschaften)) {
if (wert === undefined || wert === null) continue;
if (schluessel === "klasse" || schluessel === "className") {
el.className = wert;
} else if (schluessel === "text") {
el.textContent = wert;
} else if (schluessel === "stil" || schluessel === "style") {
if (typeof wert === "string") {
el.style.cssText = wert;
} else {
Object.assign(el.style, wert);
}
} else if (schluessel.startsWith("on") && typeof wert === "function") {
const eventName = schluessel.slice(2).toLowerCase();
el.addEventListener(eventName, wert);
} else if (schluessel === "dataset" && typeof wert === "object") {
Object.assign(el.dataset, wert);
} else if (typeof wert === "boolean") {
if (wert) el.setAttribute(schluessel, "");
else el.removeAttribute(schluessel);
} else {
el.setAttribute(schluessel, String(wert));
}
}
for (const kind of kinder) {
if (kind === null || kind === undefined) continue;
if (typeof kind === "string" || typeof kind === "number") {
el.append(document.createTextNode(String(kind)));
} else if (kind instanceof Node) {
el.append(kind);
}
}
return el;
}
/**
* Erzeugt ein SVG-Icon sicher über den SVG-Namensraum ohne innerHTML.
* @param {string} pfadDaten SVG path d attribute
* @param {number} [groesse=16]
* @param {string} [klasse=""]
* @returns {SVGSVGElement}
*/
export function erstelleIcon(pfadDaten, groesse = 16, klasse = "") {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", String(groesse));
svg.setAttribute("height", String(groesse));
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.75");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
if (klasse) svg.setAttribute("class", `icon ${klasse}`);
else svg.setAttribute("class", "icon");
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", pfadDaten);
svg.appendChild(path);
return svg;
}
/**
* Häufig genutzte SVG-Pfade (Lucide-kompatibel, 24x24).
*/
export const PFADE = {
chat: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z",
agenten: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75",
aufgaben: "M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
dienste: "M4 14a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6z M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z M12 6v6 M8 8h.01 M16 8h.01",
verbrauch: "M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7",
staging: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4",
protokoll: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6",
sicherung: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8",
einstellungen: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M12 16v-4 M12 8h.01",
plus: "M12 5v14 M5 12h14",
muelleimer: "M3 6h18 M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M10 11v6 M14 11v6",
abspielen: "M5 3l14 9-14 9V3z",
abbrechen: "M18 6L6 18 M6 6l12 12",
senden: "M22 2L11 13 M22 2l-7 20-4-9-9-4 20-7z",
leeren: "M19 7l-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16",
aktualisieren: "M21.5 2v6h-6 M2.5 22v-6h6 M2 11.5a10 10 0 0 1 18.8-4.3L21.5 8 M22 12.5a10 10 0 0 1-18.8 4.2L2.5 16",
sonne: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
mond: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z",
menue: "M3 12h18 M3 6h18 M3 18h18",
schliessen: "M18 6L6 18 M6 6l12 12",
pfeilLinks: "M19 12H5 M12 19l-7-7 7-7",
pfeilRechts: "M5 12h14 M12 5l7 7-7 7",
pfeilUnten: "M6 9l6 6 6-6",
haken: "M20 6L9 17l-5-5",
ausrufezeichen: "M12 8v4 M12 16h.01 M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z",
ausloggen: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9"
};
/**
* Erzeugt eine Statusplakette (Badge).
* @param {string} text
* @param {"erfolg"|"warnung"|"gefahr"|"neutral"|"laufend"} art
* @returns {HTMLElement}
*/
export function erstellePlakette(text, art = "neutral") {
return erstelleElement("span", { klasse: `plakette plakette-${art}`, text });
}
/**
* Erstellt eine Karte mit optionalem Blueprint-Rahmen.
* @param {string} [titel]
* @param {HTMLElement[]} [elemente]
* @param {object} [optionen]
* @returns {HTMLElement}
*/
export function erstelleKarte(titel, elemente = [], optionen = {}) {
const card = erstelleElement("div", {
klasse: `karte ${optionen.klasse ?? ""}`.trim()
});
if (titel) {
const kopf = erstelleElement("div", { klasse: "karte-kopf" });
const h = erstelleElement("h2", { text: titel });
kopf.append(h);
if (optionen.aktionen) kopf.append(optionen.aktionen);
card.append(kopf);
}
for (const el of elemente) {
if (el) card.append(el);
}
return card;
}
/**
* Erzeugt einen standardisierten Modaldialog.
* @param {string} titel
* @param {HTMLElement} inhalt
* @param {HTMLElement[]} [aktionen]
* @returns {{ dialog: HTMLElement, oeffnen: () => void, schliessen: () => void }}
*/
export function erstelleModal(titel, inhalt, aktionen = []) {
const overlay = erstelleElement("div", { klasse: "modal-overlay", hidden: true });
const box = erstelleElement("div", { klasse: "modal-box karte" });
const kopf = erstelleElement("div", { klasse: "modal-kopf" });
const h = erstelleElement("h2", { text: titel });
const schliessenBtn = erstelleElement(
"button",
{ klasse: "schaltflaeche-icon flach", title: "Schließen", type: "button" },
erstelleIcon(PFADE.schliessen, 16)
);
kopf.append(h, schliessenBtn);
const koerper = erstelleElement("div", { klasse: "modal-koerper" }, inhalt);
const fuss = erstelleElement("div", { klasse: "modal-fuss" });
for (const aktion of aktionen) fuss.append(aktion);
box.append(kopf, koerper, fuss);
overlay.append(box);
document.body.append(overlay);
const schliessen = () => {
overlay.hidden = true;
overlay.classList.remove("sichtbar");
document.removeEventListener("keydown", tasteHandler);
};
const oeffnen = () => {
overlay.hidden = false;
overlay.classList.add("sichtbar");
document.addEventListener("keydown", tasteHandler);
const erstesInput = box.querySelector("input, select, textarea, button:not(.schaltflaeche-icon)");
erstesInput?.focus();
};
const tasteHandler = (e) => {
if (e.key === "Escape") schliessen();
};
schliessenBtn.addEventListener("click", schliessen);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) schliessen();
});
return { dialog: overlay, oeffnen, schliessen };
}
/**
* Formatiert Zeitangaben (z.B. "14:32:05").
* @param {string|Date|number|null} wert
* @returns {string}
*/
export function formatiereZeit(wert) {
if (!wert) return "—";
const d = wert instanceof Date ? wert : new Date(wert);
if (isNaN(d.getTime())) return String(wert);
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
/**
* Formatiert Datum und Uhrzeit.
* @param {string|Date|number|null} wert
* @returns {string}
*/
export function formatiereDatumZeit(wert) {
if (!wert) return "—";
const d = wert instanceof Date ? wert : new Date(wert);
if (isNaN(d.getTime())) return String(wert);
return d.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
/**
* Formatiert Tokenzahlen mit Tausendertrennzeichen.
* @param {number|null} zahl
* @returns {string}
*/
export function formatiereTokens(zahl) {
if (zahl === null || zahl === undefined) return "0";
return new Intl.NumberFormat("de-DE").format(zahl);
}
/**
* Formatiert Kosten (USD / EUR).
* @param {number|null} kosten
* @returns {string}
*/
export function formatiereKosten(kosten) {
if (kosten === null || kosten === undefined) return "$0.00";
return "$" + Number(kosten).toFixed(4);
}
/**
* Zeigt ein kurzes Benachrichtigungsbanner oben im Fenster.
* @param {string} text
* @param {"info"|"erfolg"|"warnung"|"gefahr"} [art="info"]
* @param {number} [dauerMs=4000]
*/
export function zeigeMeldung(text, art = "info", dauerMs = 4000) {
let container = document.getElementById("toast-container");
if (!container) {
container = erstelleElement("div", { id: "toast-container", klasse: "toast-container" });
document.body.append(container);
}
const toast = erstelleElement("div", { klasse: `toast toast-${art}` });
const iconArt = art === "erfolg" ? PFADE.haken : art === "warnung" || art === "gefahr" ? PFADE.ausrufezeichen : PFADE.info;
toast.append(erstelleIcon(iconArt, 16), erstelleElement("span", { text }));
container.append(toast);
setTimeout(() => {
toast.classList.add("ausblenden");
setTimeout(() => toast.remove(), 300);
}, dauerMs);
}
@@ -0,0 +1,119 @@
// ClawdDotNet — Ereignisstrom (Server-Sent Events)
//
// Hört auf /events und verteilt anwendungsintern Ereignisse (chat, step, run, instance, status).
import { holeToken } from "./api.js";
let strom = null;
const abonnenten = new Map();
const verbindungsStatusCallbacks = new Set();
let verbunden = false;
/**
* Registriert einen Rückruf für Änderungen des Verbindungsstatus (offen / getrennt).
* @param {(status: boolean) => void} callback
*/
export function beiVerbindungsStatus(callback) {
verbindungsStatusCallbacks.add(callback);
callback(verbunden);
return () => verbindungsStatusCallbacks.delete(callback);
}
function meldeStatus(istOffen) {
if (verbunden === istOffen) return;
verbunden = istOffen;
for (const cb of verbindungsStatusCallbacks) {
try {
cb(verbunden);
} catch (err) {
console.error("Fehler im Verbindungsstatus-Callback:", err);
}
}
}
/**
* Abonniert ein bestimmtes Server-Ereignis.
* @param {string} ereignis Name des Ereignisses ("chat", "step", "run", "instance", "status")
* @param {(daten: any) => void} callback
* @returns {() => void} Abmeldefunktion
*/
export function abonnieren(ereignis, callback) {
if (!abonnenten.has(ereignis)) {
abonnenten.set(ereignis, new Set());
}
abonnenten.get(ereignis).add(callback);
return () => {
const s = abonnenten.get(ereignis);
if (s) {
s.delete(callback);
if (s.size === 0) abonnenten.delete(ereignis);
}
};
}
function verteileEreignis(ereignis, rohdaten) {
const gruppe = abonnenten.get(ereignis);
if (!gruppe || gruppe.size === 0) return;
let daten = rohdaten;
try {
daten = JSON.parse(rohdaten);
} catch {
// Falls Rohdaten kein JSON waren, unverändert übergeben
}
for (const cb of gruppe) {
try {
cb(daten);
} catch (err) {
console.error(`Fehler bei Verarbeitung von ${ereignis}:`, err);
}
}
}
/**
* Startet den Ereignisstrom, falls ein Token vorliegt.
*/
export function verbindeStrom() {
trenneStrom();
const token = holeToken();
if (!token) return;
// EventSource kann keine Header setzen: Token wird über die Query übergeben (siehe TokenGate.cs)
strom = new EventSource(`/events?token=${encodeURIComponent(token)}`);
strom.onopen = () => {
meldeStatus(true);
};
// EventSource verbindet automatisch wieder bei Abbruch.
strom.onerror = () => {
meldeStatus(false);
};
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
for (const art of bekannteEreignisse) {
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
}
}
/**
* Schließt die bestehende Verbindung zum Ereignisstrom.
*/
export function trenneStrom() {
if (strom) {
strom.close();
strom = null;
}
meldeStatus(false);
}
/**
* Gibt an, ob der Ereignisstrom aktuell aktiv verbunden ist.
* @returns {boolean}
*/
export function istVerbunden() {
return verbunden;
}
@@ -0,0 +1,123 @@
// ClawdDotNet — Hash-basierter Router
//
// Leitfaden-Regel 3.2: Navigation über #. Kein history.pushState, keine Serverrouten.
const routen = new Map();
let aktiverPfad = "";
let navigationsListener = new Set();
/**
* Registriert einen Handler für eine Route.
* @param {string} muster z.B. "/chat", "/chat/:agentId", "/agenten"
* @param {(parameter: object) => Promise<void>|void} handler
*/
export function registriereRoute(muster, handler) {
routen.set(muster, handler);
}
/**
* Registriert einen allgemeinen Listener, der bei jedem Routenwechsel aufgerufen wird.
* @param {(pfad: string, parameter: object) => void} listener
*/
export function beiRoutenwechsel(listener) {
navigationsListener.add(listener);
return () => navigationsListener.delete(listener);
}
/**
* Navigiert zu einem bestimmten Hash.
* @param {string} hash z.B. "#/agenten" oder "/agenten"
*/
export function navigiere(hash) {
const bereinigt = hash.startsWith("#") ? hash : `#${hash}`;
if (window.location.hash !== bereinigt) {
window.location.hash = bereinigt;
} else {
fuehreRouteAus();
}
}
function parseHash() {
const hash = window.location.hash || "#/chat";
const pfad = hash.startsWith("#") ? hash.slice(1) : hash;
return pfad.startsWith("/") ? pfad : `/${pfad}`;
}
function findePassendeRoute(pfad) {
const pfadTeile = pfad.split("/").filter(Boolean);
for (const [muster, handler] of routen.entries()) {
const musterTeile = muster.split("/").filter(Boolean);
if (pfadTeile.length !== musterTeile.length) continue;
const params = {};
let passt = true;
for (let i = 0; i < musterTeile.length; i++) {
if (musterTeile[i].startsWith(":")) {
const paramName = musterTeile[i].slice(1);
params[paramName] = decodeURIComponent(pfadTeile[i]);
} else if (musterTeile[i] !== pfadTeile[i]) {
passt = false;
break;
}
}
if (passt) {
return { handler, params, muster };
}
}
// Fallback: Erste Route oder 404
return null;
}
async function fuehreRouteAus() {
const pfad = parseHash();
aktiverPfad = pfad;
aktualisiereAktivenNavEintrag(pfad);
const match = findePassendeRoute(pfad);
if (match) {
for (const listener of navigationsListener) {
listener(pfad, match.params);
}
await match.handler(match.params);
} else if (routen.has("/chat")) {
navigiere("#/chat");
}
}
function aktualisiereAktivenNavEintrag(pfad) {
const basisPfad = "/" + pfad.split("/").filter(Boolean)[0];
const navLinks = document.querySelectorAll(".nav-link[href]");
for (const link of navLinks) {
const href = link.getAttribute("href") || "";
const zielPfad = href.startsWith("#") ? href.slice(1) : href;
const zielBasis = "/" + zielPfad.split("/").filter(Boolean)[0];
if (basisPfad === zielBasis) {
link.classList.add("aktiv");
} else {
link.classList.remove("aktiv");
}
}
}
/**
* Initialisiert den Router und startet die Überwachung.
*/
export function initialisiereRouter() {
window.addEventListener("hashchange", fuehreRouteAus);
fuehreRouteAus();
}
/**
* Gibt den aktuellen Pfad zurück.
* @returns {string}
*/
export function holeAktuellenPfad() {
return aktiverPfad;
}
@@ -0,0 +1,54 @@
// ClawdDotNet — Theme-Verwaltung (Hell / Dunkel)
//
// Farbschema nach Theme.axaml / Mockup. Unterstützt system, hell, dunkel.
const THEME_SCHLUESSEL = "clawd.theme";
let aktuellerModus = localStorage.getItem(THEME_SCHLUESSEL) || "system";
const systemQuery = window.matchMedia("(prefers-color-scheme: dark)");
function wendeThemeAn() {
const istDunkel =
aktuellerModus === "dunkel" ||
(aktuellerModus === "system" && systemQuery.matches);
if (istDunkel) {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.setAttribute("data-theme", "light");
}
}
systemQuery.addEventListener("change", () => {
if (aktuellerModus === "system") {
wendeThemeAn();
}
});
/**
* Initialisiert das Theme beim Start.
*/
export function initialisiereTheme() {
wendeThemeAn();
}
/**
* Schaltet zwischen Hell und Dunkel um.
* @returns {string} Neues aktives Theme
*/
export function wechsleTheme() {
const istJetztDunkel = document.documentElement.getAttribute("data-theme") === "dark";
aktuellerModus = istJetztDunkel ? "hell" : "dunkel";
localStorage.setItem(THEME_SCHLUESSEL, aktuellerModus);
wendeThemeAn();
return aktuellerModus;
}
/**
* Gibt das aktuell gewählte Theme zurück.
* @returns {"system"|"hell"|"dunkel"}
*/
export function holeAktuellesTheme() {
return aktuellerModus;
}
+413
View File
@@ -0,0 +1,413 @@
// ClawdDotNet — Agenten-Verwaltung
//
// Übersicht, Anlegen, Löschen von Agenten, Werkzeug-Schalter und maskierte Einstellungen.
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
zeigeMeldung
} from "../modules/dom.js";
let gewaehlterAgentId = null;
let agenten = [];
let entladeEreignisse = [];
export async function rendereAgenten(parameter = {}) {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "agenten-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Agenten" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerAgentDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Agent" })
)
);
const raster = erstelleElement("div", { klasse: "agenten-raster" });
const listeContainer = erstelleElement("div", { klasse: "agenten-liste-bereich" });
const detailContainer = erstelleElement("div", { klasse: "agenten-detail-bereich" });
raster.append(listeContainer, detailContainer);
layout.append(kopfZeile, raster);
container.append(layout);
await ladeUndRendereListe(parameter.agentId);
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Agents") {
ladeUndRendereListe(gewaehlterAgentId);
}
})
);
}
async function ladeUndRendereListe(zielId = null) {
const listeContainer = document.querySelector(".agenten-liste-bereich");
if (!listeContainer) return;
listeContainer.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Agenten…" })
);
try {
agenten = await api("/agents");
listeContainer.replaceChildren();
if (!agenten || agenten.length === 0) {
listeContainer.append(
erstelleElement("div", {
klasse: "hinweis-zentriert",
text: "Keine Agenten eingerichtet."
})
);
rendereDetails(null);
return;
}
const auswahlId = zielId && agenten.some((a) => a.agentId === zielId)
? zielId
: agenten[0].agentId;
for (const a of agenten) {
const eintrag = erstelleElement("div", {
klasse: `agenten-karte ${a.agentId === auswahlId ? "aktiv" : ""}`,
onclick: () => {
document.querySelectorAll(".agenten-karte").forEach((k) => k.classList.remove("aktiv"));
eintrag.classList.add("aktiv");
rendereDetails(a.agentId);
}
});
const top = erstelleElement("div", { klasse: "agent-karte-top" });
const name = erstelleElement("strong", { text: a.displayName });
const status = a.isRunning
? erstellePlakette("Beschäftigt", "warnung")
: erstellePlakette("Bereit", "neutral");
top.append(name, status);
const meta = erstelleElement("div", { klasse: "agent-karte-meta" });
meta.append(
erstelleElement("span", { klasse: "gedaempft", text: `ID: ${a.agentId}` }),
erstelleElement("span", { klasse: "gedaempft", text: ` · Modell: ${a.model}` })
);
const werkzeugeZaehler = a.tools ? a.tools.filter((t) => t.enabled).length : 0;
const werkzeugeText = erstelleElement("div", {
klasse: "agent-karte-werkzeuge gedaempft",
text: `${werkzeugeZaehler} Werkzeuge aktiv`
});
eintrag.append(top, meta, werkzeugeText);
listeContainer.append(eintrag);
}
rendereDetails(auswahlId);
} catch (err) {
listeContainer.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function rendereDetails(agentId) {
gewaehlterAgentId = agentId;
const detailContainer = document.querySelector(".agenten-detail-bereich");
if (!detailContainer) return;
detailContainer.replaceChildren();
if (!agentId) {
detailContainer.append(
erstelleKarte("Agent-Details", [
erstelleElement("p", { klasse: "gedaempft", text: "Wähle links einen Agenten aus." })
])
);
return;
}
const agent = agenten.find((a) => a.agentId === agentId);
if (!agent) return;
// Kopf-Aktionen (Chatten, Löschen)
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const zumChatBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Mit diesem Agenten chatten",
onclick: () => {
window.location.hash = `#/chat/${encodeURIComponent(agent.agentId)}`;
}
},
erstelleIcon(PFADE.chat, 14),
erstelleElement("span", { text: " Chatten" })
);
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach",
title: "Diesen Agenten löschen",
onclick: () => loescheAgent(agent.agentId, agent.displayName)
},
erstelleIcon(PFADE.muelleimer, 14),
erstelleElement("span", { text: " Löschen" })
);
aktionen.append(zumChatBtn, loeschenBtn);
// Stammdaten-Karte
const stammdatenListe = erstelleElement("dl", { klasse: "datenliste" });
const felder = [
["Name", agent.displayName],
["Kennung (ID)", agent.agentId],
["Modell", agent.model],
["Beschreibung", agent.description || "—"]
];
for (const [bez, wert] of felder) {
stammdatenListe.append(
erstelleElement("dt", { text: bez }),
erstelleElement("dd", { text: wert })
);
}
const basisKarte = erstelleKarte(agent.displayName, [stammdatenListe], { aktionen });
// Werkzeuge-Karte
const werkzeugeListe = erstelleElement("div", { klasse: "werkzeuge-liste" });
if (!agent.tools || agent.tools.length === 0) {
werkzeugeListe.append(
erstelleElement("p", { klasse: "gedaempft", text: "Keine Werkzeuge für diesen Agenten konfiguriert." })
);
} else {
for (const tool of agent.tools) {
const zeile = erstelleElement("div", { klasse: "werkzeug-zeile" });
const links = erstelleElement("div", { klasse: "werkzeug-info" });
const toolTitel = erstelleElement("strong", { text: tool.toolName });
const toolDesc = erstelleElement("div", {
klasse: "werkzeug-beschreibung gedaempft",
text: tool.description || ""
});
links.append(toolTitel, toolDesc);
const rechts = erstelleElement("div", { klasse: "werkzeug-aktionen" });
const einstellungenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
title: "Werkzeug-Einstellungen einsehen",
onclick: () => zeigeWerkzeugEinstellungen(agent.agentId, tool.toolName)
},
erstelleIcon(PFADE.einstellungen, 14),
erstelleElement("span", { text: " Konfiguration" })
);
const toggleLabel = erstelleElement("label", { klasse: "schalter" });
const toggleInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(tool.enabled),
onchange: async (e) => {
const aktiv = e.target.checked;
try {
await api(
`/agents/${encodeURIComponent(agent.agentId)}/tools/${encodeURIComponent(tool.toolName)}?enabled=${aktiv}`,
{ method: "PUT" }
);
tool.enabled = aktiv;
zeigeMeldung(`Werkzeug '${tool.toolName}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
} catch (err) {
e.target.checked = !aktiv;
zeigeMeldung(`Änderung fehlgeschlagen: ${err.message}`, "gefahr");
}
}
});
const toggleSpan = erstelleElement("span", { klasse: "schalter-regler" });
toggleLabel.append(toggleInput, toggleSpan);
rechts.append(einstellungenBtn, toggleLabel);
zeile.append(links, rechts);
werkzeugeListe.append(zeile);
}
}
const werkzeugeKarte = erstelleKarte("Verfügbare Werkzeuge", [werkzeugeListe]);
detailContainer.append(basisKarte, werkzeugeKarte);
}
function oeffneNeuerAgentDialog() {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const idFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. scout",
id: "neu-agent-id"
});
const nameFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. Scout Agent",
id: "neu-agent-name"
});
const modellFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. anthropic/claude-3.5-sonnet",
value: "anthropic/claude-3.5-sonnet",
id: "neu-agent-modell"
});
const descFeld = erstelleElement("textarea", {
rows: "3",
placeholder: "Optionale Beschreibung des Agenten…",
id: "neu-agent-desc"
});
formular.append(
erstelleElement("label", { text: "Agent-ID (eindeutiger Schlüssel):" }),
idFeld,
erstelleElement("label", { text: "Anzeigename:" }),
nameFeld,
erstelleElement("label", { text: "Modell (OpenRouter):" }),
modellFeld,
erstelleElement("label", { text: "Beschreibung:" }),
descFeld
);
const abbrechenBtn = erstelleElement(
"button",
{
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen()
},
erstelleElement("span", { text: "Abbrechen" })
);
const speichernBtn = erstelleElement(
"button",
{
type: "submit",
klasse: "schaltflaeche primaer"
},
erstelleElement("span", { text: "Agent anlegen" })
);
const modal = erstelleModal("Neuen Agenten anlegen", formular, [abbrechenBtn, speichernBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
speichernBtn.disabled = true;
try {
await api("/agents", {
method: "POST",
body: {
agentId: idFeld.value.trim(),
displayName: nameFeld.value.trim(),
model: modellFeld.value.trim(),
description: descFeld.value.trim()
}
});
modal.schliessen();
zeigeMeldung(`Agent '${nameFeld.value.trim()}' wurde erfolgreich angelegt.`, "erfolg");
await ladeUndRendereListe(idFeld.value.trim());
} catch (err) {
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
} finally {
speichernBtn.disabled = false;
}
});
modal.oeffnen();
}
async function loescheAgent(agentId, name) {
if (!confirm(`Soll der Agent '${name}' (${agentId}) wirklich gelöscht werden?`)) return;
try {
await api(`/agents/${encodeURIComponent(agentId)}`, { method: "DELETE" });
zeigeMeldung(`Agent '${name}' wurde gelöscht.`, "info");
await ladeUndRendereListe();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function zeigeWerkzeugEinstellungen(agentId, toolName) {
const box = erstelleElement("div", { klasse: "einstellungen-box" });
box.append(erstelleElement("p", { klasse: "gedaempft", text: "Lade Konfiguration…" }));
const schliessenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Schließen"
});
const modal = erstelleModal(
`Einstellungen: ${toolName} (${agentId})`,
box,
[schliessenBtn]
);
modal.oeffnen();
try {
const daten = await api(
`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/settings`
);
box.replaceChildren();
const hinweis = erstelleElement("p", {
klasse: "hinweis",
text: "Geheime Werte (Passwörter, Schlüssel) werden vom Server maskiert ausgegeben."
});
box.append(hinweis);
if (!daten || Object.keys(daten).length === 0) {
box.append(
erstelleElement("p", { klasse: "gedaempft", text: "Keine individuellen Einstellungen hinterlegt." })
);
return;
}
const dl = erstelleElement("dl", { klasse: "datenliste" });
for (const [schluessel, wert] of Object.entries(daten)) {
const dt = erstelleElement("dt", { text: schluessel });
const dd = erstelleElement("dd", { text: typeof wert === "object" ? JSON.stringify(wert) : String(wert) });
dl.append(dt, dd);
}
box.append(dl);
} catch (err) {
box.replaceChildren(
erstelleElement("p", { klasse: "fehler", text: `Laden fehlgeschlagen: ${err.message}` })
);
}
}
+394
View File
@@ -0,0 +1,394 @@
// ClawdDotNet — Chat-Ansicht
//
// Leitfaden: Sicherer Chatverlauf (strikt textContent), Echtzeit-Ereignisse über SSE.
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
formatiereZeit,
erstellePlakette,
zeigeMeldung
} from "../modules/dom.js";
let aktiverAgentId = null;
let agentenListe = [];
let nachrichtenContainer = null;
let schrittBox = null;
let eingabeTextarea = null;
let sendenButton = null;
let abbrechenButton = null;
let agentenSelector = null;
let statusPlakette = null;
let entladeEreignisse = [];
export async function rendereChat(parameter = {}) {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
// Alte Ereignisse aufräumen
entladeEreignisse.forEach((cleanup) => cleanup());
entladeEreignisse = [];
const chatShell = erstelleElement("div", { klasse: "chat-ansicht" });
// 1. Kopfleiste der Chat-Ansicht
const kopfleiste = erstelleElement("div", { klasse: "chat-kopf" });
const agentenWahl = erstelleElement("div", { klasse: "chat-agenten-wahl" });
agentenSelector = erstelleElement("select", {
klasse: "eingabe-select",
onchange: (e) => waehleAgent(e.target.value)
});
statusPlakette = erstellePlakette("Lädt…", "neutral");
agentenWahl.append(erstelleElement("label", { text: "Agent: " }), agentenSelector, statusPlakette);
const aktionen = erstelleElement("div", { klasse: "chat-aktionen" });
const weckenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Agenten manuell ausführen / wecken",
onclick: weckeAktivenAgent
},
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Wecken" })
);
const leerenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Chatverlauf dieses Agenten leeren",
onclick: leereChatverlauf
},
erstelleIcon(PFADE.leeren, 14),
erstelleElement("span", { text: " Verlauf leeren" })
);
abbrechenButton = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach",
title: "Laufenden Lauf abbrechen",
hidden: true,
onclick: brecheChatAb
},
erstelleIcon(PFADE.abbrechen, 14),
erstelleElement("span", { text: " Abbrechen" })
);
aktionen.append(weckenBtn, leerenBtn, abbrechenButton);
kopfleiste.append(agentenWahl, aktionen);
// 2. Nachrichtenverlauf & Schrittanzeige
nachrichtenContainer = erstelleElement("div", { klasse: "chat-verlauf" });
schrittBox = erstelleElement("div", { klasse: "chat-schritt-box", hidden: true });
const schrittIcon = erstelleIcon(PFADE.aktualisieren, 14, "rotierend");
const schrittText = erstelleElement("span", { klasse: "schritt-text", text: "" });
schrittBox.append(schrittIcon, schrittText);
// 3. Eingabebereich
const eingabeBereich = erstelleElement("div", { klasse: "chat-eingabe-bereich" });
eingabeTextarea = erstelleElement("textarea", {
klasse: "chat-eingabe",
placeholder: "Nachricht an den Agenten eingeben (Enter zum Senden, Umschalt+Enter für neue Zeile)…",
rows: "3",
onkeydown: (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendeNachricht();
}
}
});
sendenButton = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
title: "Nachricht senden",
onclick: sendeNachricht
},
erstelleIcon(PFADE.senden, 16),
erstelleElement("span", { text: "Senden" })
);
eingabeBereich.append(eingabeTextarea, sendenButton);
chatShell.append(kopfleiste, nachrichtenContainer, schrittBox, eingabeBereich);
container.append(chatShell);
// Lade Agenten und initialisiere
await ladeAgenten(parameter.agentId);
// SSE-Abonnements
entladeEreignisse.push(
abonnieren("chat", (daten) => {
if (daten.agentId === aktiverAgentId) {
fuegeNachrichtEin(daten);
}
})
);
entladeEreignisse.push(
abonnieren("step", (daten) => {
if (daten.agentId === aktiverAgentId) {
zeigeSchritt(daten.text);
}
})
);
entladeEreignisse.push(
abonnieren("run", (daten) => {
versteckeSchritt();
aktualisiereLaufStatus(false);
})
);
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Agents") {
ladeAgenten(aktiverAgentId);
}
})
);
}
async function ladeAgenten(gewuenschteId = null) {
try {
agentenListe = await api("/agents");
agentenSelector.replaceChildren();
if (!agentenListe || agentenListe.length === 0) {
const opt = erstelleElement("option", { text: "Keine Agenten vorhanden", value: "" });
agentenSelector.append(opt);
statusPlakette.replaceWith(erstellePlakette("Keine Agenten", "warnung"));
return;
}
for (const a of agentenListe) {
const opt = erstelleElement("option", {
value: a.agentId,
text: `${a.displayName} (${a.agentId})`
});
agentenSelector.append(opt);
}
const zielId = gewuenschteId && agentenListe.some((a) => a.agentId === gewuenschteId)
? gewuenschteId
: agentenListe[0].agentId;
agentenSelector.value = zielId;
await waehleAgent(zielId);
} catch (err) {
zeigeMeldung(`Fehler beim Laden der Agenten: ${err.message}`, "gefahr");
}
}
async function waehleAgent(agentId) {
aktiverAgentId = agentId;
const agent = agentenListe.find((a) => a.agentId === agentId);
aktualisiereLaufStatus(agent?.isRunning ?? false);
window.location.hash = `#/chat/${encodeURIComponent(agentId)}`;
await ladeChatVerlauf(agentId);
}
function aktualisiereLaufStatus(laeuft) {
if (statusPlakette) {
const neuePlakette = laeuft
? erstellePlakette("Beschäftigt", "warnung")
: erstellePlakette("Bereit", "erfolg");
statusPlakette.replaceWith(neuePlakette);
statusPlakette = neuePlakette;
}
if (abbrechenButton) {
abbrechenButton.hidden = !laeuft;
}
}
async function ladeChatVerlauf(agentId) {
if (!nachrichtenContainer) return;
nachrichtenContainer.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Verlauf…" })
);
try {
const verlauf = await api(`/agents/${encodeURIComponent(agentId)}/chat`);
nachrichtenContainer.replaceChildren();
if (!verlauf || verlauf.length === 0) {
nachrichtenContainer.append(
erstelleElement("div", {
klasse: "hinweis-zentriert",
text: "Noch keine Nachrichten im Verlauf vorhanden."
})
);
return;
}
for (const eintrag of verlauf) {
fuegeNachrichtEin(eintrag);
}
scrolleNachUnten();
} catch (err) {
nachrichtenContainer.replaceChildren(
erstelleElement("div", {
klasse: "fehler-zentriert",
text: `Verlauf konnte nicht geladen werden: ${err.message}`
})
);
}
}
function fuegeNachrichtEin(eintrag) {
// Falls aktuell der Hinweis angezeigt wird, entfernen
const platzhalter = nachrichtenContainer.querySelector(".hinweis-zentriert");
if (platzhalter) platzhalter.remove();
const rolle = (eintrag.role || "user").toLowerCase();
const istUser = rolle === "user";
const istSystem = rolle === "system";
const istTool = rolle === "tool";
let rollenKlasse = "nachricht-benutzer";
let rollenTitel = "Benutzer";
if (istSystem) {
rollenKlasse = "nachricht-system";
rollenTitel = "System";
} else if (istTool) {
rollenKlasse = "nachricht-werkzeug";
rollenTitel = "Werkzeug";
} else if (!istUser) {
rollenKlasse = "nachricht-agent";
rollenTitel = agentenListe.find((a) => a.agentId === aktiverAgentId)?.displayName || "Agent";
}
const zeile = erstelleElement("div", { klasse: `chat-zeile ${istUser ? "zeile-benutzer" : "zeile-fremd"}` });
const nachrichtCard = erstelleElement("div", { klasse: `nachricht-blase ${rollenKlasse}` });
const kopf = erstelleElement("div", { klasse: "nachricht-meta" });
const autor = erstelleElement("strong", { text: rollenTitel });
const zeit = erstelleElement("span", {
klasse: "nachricht-zeit",
text: formatiereZeit(eintrag.timestamp)
});
kopf.append(autor);
if (eintrag.source) {
const quelleBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.source });
kopf.append(quelleBadge);
}
kopf.append(zeit);
// WICHTIG (Regel 3.1): Fremdtext ist NIE HTML! Strikt textContent!
const textKörper = erstelleElement("div", {
klasse: "nachricht-text",
text: eintrag.content || ""
});
nachrichtCard.append(kopf, textKörper);
zeile.append(nachrichtCard);
nachrichtenContainer.append(zeile);
scrolleNachUnten();
}
function zeigeSchritt(text) {
if (!schrittBox) return;
const span = schrittBox.querySelector(".schritt-text");
if (span) span.textContent = text;
schrittBox.hidden = false;
aktualisiereLaufStatus(true);
}
function versteckeSchritt() {
if (schrittBox) schrittBox.hidden = true;
}
function scrolleNachUnten() {
if (nachrichtenContainer) {
nachrichtenContainer.scrollTop = nachrichtenContainer.scrollHeight;
}
}
async function sendeNachricht() {
if (!aktiverAgentId || !eingabeTextarea) return;
const nachricht = eingabeTextarea.value.trim();
if (!nachricht) return;
eingabeTextarea.value = "";
sendenButton.disabled = true;
aktualisiereLaufStatus(true);
try {
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat`, {
method: "POST",
body: { message: nachricht }
});
} catch (err) {
zeigeMeldung(`Nachricht konnte nicht gesendet werden: ${err.message}`, "gefahr");
aktualisiereLaufStatus(false);
} finally {
sendenButton.disabled = false;
eingabeTextarea.focus();
}
}
async function brecheChatAb() {
if (!aktiverAgentId) return;
try {
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat/abort`, {
method: "POST"
});
versteckeSchritt();
aktualisiereLaufStatus(false);
zeigeMeldung("Lauf wurde abgebrochen.", "warnung");
} catch (err) {
zeigeMeldung(`Abbruch fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function weckeAktivenAgent() {
if (!aktiverAgentId) return;
try {
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/run`, {
method: "POST"
});
aktualisiereLaufStatus(true);
zeigeMeldung("Agent wurde manuell gestartet.", "info");
} catch (err) {
zeigeMeldung(`Starten fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function leereChatverlauf() {
if (!aktiverAgentId) return;
if (!confirm("Soll der gesamte Chatverlauf dieses Agenten unwiderruflich gelöscht werden?")) return;
try {
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat`, {
method: "DELETE"
});
nachrichtenContainer.replaceChildren(
erstelleElement("div", {
klasse: "hinweis-zentriert",
text: "Verlauf wurde geleert."
})
);
zeigeMeldung("Chatverlauf geleert.", "info");
} catch (err) {
zeigeMeldung(`Leeren fehlgeschlagen: ${err.message}`, "gefahr");
}
}
+90
View File
@@ -0,0 +1,90 @@
// ClawdDotNet — System-Info & Status
//
// Zeigt Anwendungsversion, Instanzkennung, Teilsysteme und OpenRouter-Guthaben an.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
erstellePlakette
} from "../modules/dom.js";
export async function rendereInfo() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "info-ansicht" });
layout.append(erstelleElement("h1", { text: "System & Status" }));
const gitter = erstelleElement("div", { klasse: "info-gitter" });
layout.append(gitter);
container.append(layout);
gitter.append(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Systemdaten…" })
);
try {
const [info, status] = await Promise.all([
api("/info").catch(() => null),
api("/status").catch(() => null)
]);
gitter.replaceChildren();
// 1. Instanz & Version
if (info) {
const dlInstanz = erstelleElement("dl", { klasse: "datenliste" });
dlInstanz.append(
erstelleElement("dt", { text: "Instanzname" }),
erstelleElement("dd", {}, erstelleElement("strong", { text: info.instanceName })),
erstelleElement("dt", { text: "Instanz-ID" }),
erstelleElement("dd", {}, erstelleElement("code", { text: info.instanceId })),
erstelleElement("dt", { text: "Version" }),
erstelleElement("dd", { text: info.appVersion }),
erstelleElement("dt", { text: "Build" }),
erstelleElement("dd", { text: info.buildSummary })
);
gitter.append(erstelleKarte("Instanz & Programm", [dlInstanz]));
// 2. Teilsysteme
const dlSysteme = erstelleElement("dl", { klasse: "datenliste" });
dlSysteme.append(
erstelleElement("dt", { text: "Agent Engine" }),
erstelleElement("dd", {}, erstellePlakette(info.engineActive ? "Aktiv" : "Aus", info.engineActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Scanner / Scheduler" }),
erstelleElement("dd", {}, erstellePlakette(info.schedulerActive ? "Aktiv" : "Aus", info.schedulerActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Freigabestelle (Staging)" }),
erstelleElement("dd", {}, erstellePlakette(info.stagingActive ? "Aktiv" : "Aus", info.stagingActive ? "erfolg" : "neutral")),
erstelleElement("dt", { text: "Deploymentcenter" }),
erstelleElement("dd", {}, erstellePlakette(info.deploymentcenterActive ? "Verbunden" : "Nicht konfiguriert", info.deploymentcenterActive ? "erfolg" : "neutral"))
);
gitter.append(erstelleKarte("Dienste & Teilsysteme", [dlSysteme]));
}
// 3. OpenRouter / API-Status
const dlStatus = erstelleElement("dl", { klasse: "datenliste" });
if (status) {
dlStatus.append(
erstelleElement("dt", { text: "API-Erreichbarkeit" }),
erstelleElement("dd", {}, erstellePlakette(status.isApiReachable ? "Verbunden" : "Getrennt", status.isApiReachable ? "erfolg" : "gefahr")),
erstelleElement("dt", { text: "Statusmeldung" }),
erstelleElement("dd", { text: status.statusText || "—" }),
erstelleElement("dt", { text: "Guthaben / Credits" }),
erstelleElement("dd", {}, erstelleElement("strong", { text: status.creditsText || "—" }))
);
} else {
dlStatus.append(
erstelleElement("dt", { text: "OpenRouter" }),
erstelleElement("dd", { klasse: "gedaempft", text: "Kein API-Schlüssel oder Dienst nicht erreichbar." })
);
}
gitter.append(erstelleKarte("Modellanbindung (OpenRouter)", [dlStatus]));
} catch (err) {
gitter.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
@@ -0,0 +1,71 @@
// ClawdDotNet — Anmeldeseite
//
// Leitfaden: Ohne Token soll das UI sofort auf die Login- bzw. Tokeneingabe springen.
import { setzeToken, loescheToken, api } from "../modules/api.js";
import { erstelleElement, PFADE, erstelleIcon } from "../modules/dom.js";
let loginCallback = null;
export function initialisiereLogin(onErfolg) {
loginCallback = onErfolg;
const formular = document.getElementById("anmeldeformular");
const fehlerBox = document.getElementById("anmeldefehler");
if (!formular) return;
formular.addEventListener("submit", async (e) => {
e.preventDefault();
const tokenInput = document.getElementById("token-eingabe");
const token = tokenInput?.value.trim() || "";
if (!token) return;
if (fehlerBox) {
fehlerBox.hidden = true;
fehlerBox.textContent = "";
}
setzeToken(token);
try {
// Teste das Token gegen /api/info
const info = await api("/info");
versteckeAnmeldung();
if (loginCallback) loginCallback(info);
} catch (err) {
loescheToken();
if (fehlerBox) {
fehlerBox.textContent = err.abgelehnt
? "Token ungültig oder kürzer als 20 Zeichen."
: `Anmeldefehler: ${err.message}`;
fehlerBox.hidden = false;
}
}
});
}
export function zeigeAnmeldung() {
const anmeldung = document.getElementById("anmeldung");
const anwendung = document.getElementById("anwendung");
const fehlerBox = document.getElementById("anmeldefehler");
const tokenInput = document.getElementById("token-eingabe");
if (anmeldung) anmeldung.hidden = false;
if (anwendung) anwendung.hidden = true;
if (fehlerBox) {
fehlerBox.hidden = true;
fehlerBox.textContent = "";
}
if (tokenInput) {
tokenInput.value = "";
tokenInput.focus();
}
}
export function versteckeAnmeldung() {
const anmeldung = document.getElementById("anmeldung");
const anwendung = document.getElementById("anwendung");
if (anmeldung) anmeldung.hidden = true;
if (anwendung) anwendung.hidden = false;
}
+224
View File
@@ -0,0 +1,224 @@
// ClawdDotNet — Live-Protokollansicht (Logs)
//
// Fortlaufender Stream über /events/logs, Modul- und Level-Filterung, Autoscroll, strikt textContent.
import { api, holeToken } from "../modules/api.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstellePlakette
} from "../modules/dom.js";
let logStream = null;
let modulFilter = "";
let levelFilter = "All";
let autoScroll = true;
let logContainer = null;
let streamStatusPlakette = null;
let pauseButton = null;
let istPausiert = false;
export async function rendereProtokoll() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
trenneLogStream();
const layout = erstelleElement("div", { klasse: "protokoll-ansicht" });
// Kopfleiste mit Filtern und Steuerungen
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
titelGruppe.append(erstelleElement("h1", { text: "Protokoll" }));
streamStatusPlakette = erstellePlakette("Verbinde…", "neutral");
titelGruppe.append(streamStatusPlakette);
const steuerungGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
// Modul-Auswahl
const modulSelect = erstelleElement("select", {
klasse: "eingabe-select",
onchange: (e) => {
modulFilter = e.target.value;
starteLogStream();
}
});
modulSelect.append(erstelleElement("option", { value: "", text: "Alle Module" }));
// Level-Auswahl
const levelSelect = erstelleElement("select", {
klasse: "eingabe-select",
onchange: (e) => {
levelFilter = e.target.value;
starteLogStream();
}
});
const levelOptionen = [
["All", "Alle Stufen"],
["Debug", "Debug"],
["Information", "Info"],
["Warning", "Warnung"],
["Error", "Fehler"],
["Critical", "Kritisch"]
];
for (const [val, label] of levelOptionen) {
levelSelect.append(erstelleElement("option", { value: val, text: label }));
}
// Autoscroll-Checkbox
const autoScrollLabel = erstelleElement("label", { klasse: "checkbox-label gedaempft" });
const autoScrollInput = erstelleElement("input", {
type: "checkbox",
checked: true,
onchange: (e) => {
autoScroll = e.target.checked;
}
});
autoScrollLabel.append(autoScrollInput, erstelleElement("span", { text: " Auto-Scroll" }));
// Pause / Fortsetzen
pauseButton = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
onclick: togglePause
},
erstelleIcon(PFADE.abbrechen, 14),
erstelleElement("span", { text: " Anhalten" })
);
// Leeren
const leerenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
title: "Anzeige leeren",
onclick: () => logContainer?.replaceChildren()
},
erstelleIcon(PFADE.leeren, 14),
erstelleElement("span", { text: " Leeren" })
);
steuerungGruppe.append(modulSelect, levelSelect, autoScrollLabel, pauseButton, leerenBtn);
kopfZeile.append(titelGruppe, steuerungGruppe);
logContainer = erstelleElement("div", { klasse: "protokoll-strom code-block" });
layout.append(kopfZeile, logContainer);
container.append(layout);
// Lade verfügbare Module
try {
const module = await api("/logs/modules");
if (Array.isArray(module)) {
for (const m of module) {
modulSelect.append(erstelleElement("option", { value: m, text: m }));
}
}
} catch {
// Nicht kritisch, Filter bleibt auf "Alle Module"
}
starteLogStream();
}
function starteLogStream() {
trenneLogStream();
if (istPausiert) return;
const token = holeToken();
if (!token) return;
let url = `/events/logs?token=${encodeURIComponent(token)}`;
if (modulFilter) url += `&module=${encodeURIComponent(modulFilter)}`;
if (levelFilter) url += `&level=${encodeURIComponent(levelFilter)}`;
logStream = new EventSource(url);
logStream.onopen = () => {
aktualisiereStatus(true);
};
logStream.onerror = () => {
aktualisiereStatus(false);
};
logStream.addEventListener("log", (e) => {
if (istPausiert) return;
try {
const zeile = JSON.parse(e.data);
fuegeLogZeileEin(zeile);
} catch {
fuegeLogZeileEin({ text: e.data });
}
});
}
function fuegeLogZeileEin(eintrag) {
if (!logContainer) return;
const zeile = erstelleElement("div", { klasse: "log-zeile" });
if (eintrag.level) {
const lvl = String(eintrag.level).toLowerCase();
const badgeArt =
lvl.includes("err") || lvl.includes("crit")
? "gefahr"
: lvl.includes("warn")
? "warnung"
: lvl.includes("info")
? "erfolg"
: "neutral";
zeile.append(erstelleElement("span", { klasse: `log-tag tag-${badgeArt}`, text: eintrag.level }));
}
if (eintrag.module) {
zeile.append(erstelleElement("span", { klasse: "log-modul", text: `[${eintrag.module}] ` }));
}
// Wichtig: textContent, kein innerHTML!
const textSpan = erstelleElement("span", { klasse: "log-text", text: eintrag.text || "" });
zeile.append(textSpan);
logContainer.append(zeile);
// Begrenze auf 300 Zeilen im DOM
while (logContainer.children.length > 300) {
logContainer.firstElementChild?.remove();
}
if (autoScroll) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
function togglePause() {
istPausiert = !istPausiert;
if (istPausiert) {
trenneLogStream();
pauseButton.replaceChildren(erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " Fortsetzen" }));
aktualisiereStatus(false, "Pausiert");
} else {
pauseButton.replaceChildren(erstelleIcon(PFADE.abbrechen, 14), erstelleElement("span", { text: " Anhalten" }));
starteLogStream();
}
}
function aktualisiereStatus(offen, text = null) {
if (!streamStatusPlakette) return;
const t = text || (offen ? "Live" : "Getrennt");
const art = offen ? "erfolg" : "neutral";
const neuePlakette = erstellePlakette(t, art);
streamStatusPlakette.replaceWith(neuePlakette);
streamStatusPlakette = neuePlakette;
}
export function trenneLogStream() {
if (logStream) {
logStream.close();
logStream = null;
}
}
@@ -0,0 +1,74 @@
// ClawdDotNet — Platzhalter-Ansichten für noch nicht freigegebene Kern-Endpunkte
//
// Leitfaden Abschnitt 7: Sicherung, Einstellungen und Agenten-Chats (Analyse).
import { erstelleElement, erstelleKarte, erstelleIcon, PFADE } from "../modules/dom.js";
export function rendereSicherung() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" }));
const karte = erstelleKarte("In Vorbereitung", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die API-Endpunkte für die automatisierte Sicherung und Rücksicherung über das WebUI befinden sich in Arbeit."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Bitte verwende für die Sicherung von Instanzen aktuell die Desktop-Oberfläche von ClawdDotNet."
})
]);
layout.append(karte);
container.append(layout);
}
export function rendereEinstellungen() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Instanz-Einstellungen" }));
const karte = erstelleKarte("In Vorbereitung", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die Konfiguration der Instanz (Port, Token, Logging, Budget) erfolgt über die Datei 'InstanceSettings.json' bzw. über den Einstellungsdialog im Desktop-Fenster."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Ein dedizierter Web-Endpunkt zum Schreiben sensibler Zugangsdaten ist für einen späteren Meilenstein vorgesehen."
})
]);
layout.append(karte);
container.append(layout);
}
export function rendereAgentenChats() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
layout.append(erstelleElement("h1", { text: "Agenten-Kommunikation" }));
const karte = erstelleKarte("Analyse: Interne Agenten-Chats", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Die Protokollierung und Auswertung der direkten Kommunikation zwischen zwei Agenten (A2A) wird mit Vorhaben #208 bereitgestellt."
}),
erstelleElement("p", {
klasse: "hinweis",
text: "Direkte Chats mit Agenten können im Bereich 'Chat' geführt werden."
})
]);
layout.append(karte);
container.append(layout);
}
@@ -0,0 +1,243 @@
// ClawdDotNet — Hintergrunddienste
//
// Übersicht und Verwaltung der Instanz-Dienste (Port, Protokoll, Status, Ein-/Ausschalten).
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstelleModal,
zeigeMeldung
} from "../modules/dom.js";
let services = [];
let entladeEreignisse = [];
export async function rendereDienste() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "dienste-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Dienste" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerDienstDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Dienst" })
)
);
const tabelleBereich = erstelleElement("div", { id: "dienste-liste-bereich" });
layout.append(kopfZeile, tabelleBereich);
container.append(layout);
await ladeDienste();
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Services") {
ladeDienste();
}
})
);
}
async function ladeDienste() {
const container = document.getElementById("dienste-liste-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Dienste…" })
);
try {
services = await api("/services");
container.replaceChildren();
if (!services || services.length === 0) {
container.append(
erstelleKarte("Keine Dienste", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Für diese Instanz sind noch keine Hintergrunddienste eingerichtet."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
thead.append(
erstelleElement("tr", {},
erstelleElement("th", { text: "Aktiv" }),
erstelleElement("th", { text: "Dienstname" }),
erstelleElement("th", { text: "Typ" }),
erstelleElement("th", { text: "Port" }),
erstelleElement("th", { text: "Beschreibung" }),
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
)
);
const tbody = erstelleElement("tbody");
for (const s of services) {
const tr = erstelleElement("tr");
// Schalter
const tdStatus = erstelleElement("td");
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
const schalterInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(s.enabled),
onchange: async (e) => {
const aktiv = e.target.checked;
try {
await api(`/services/${encodeURIComponent(s.id)}/enabled?enabled=${aktiv}`, {
method: "PUT"
});
s.enabled = aktiv;
zeigeMeldung(`Dienst '${s.name}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
} catch (err) {
e.target.checked = !aktiv;
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
});
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
tdStatus.append(schalterLabel);
const tdName = erstelleElement("td", {}, erstelleElement("strong", { text: s.name }));
const tdTyp = erstelleElement("td", { text: s.type || "—" });
const tdPort = erstelleElement("td", {}, s.port ? erstelleElement("code", { text: String(s.port) }) : erstelleElement("span", { text: "—" }));
const tdDesc = erstelleElement("td", { klasse: "gedaempft", text: s.description || "—" });
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
title: "Dienst löschen",
onclick: () => loescheDienst(s)
},
erstelleIcon(PFADE.muelleimer, 14)
);
tdAktionen.append(loeschenBtn);
tr.append(tdStatus, tdName, tdTyp, tdPort, tdDesc, tdAktionen);
tbody.append(tr);
}
tabelle.append(thead, tbody);
container.append(tabelle);
} catch (err) {
container.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function loescheDienst(s) {
if (!confirm(`Soll der Dienst '${s.name}' (${s.id}) gelöscht werden?`)) return;
try {
await api(`/services/${encodeURIComponent(s.id)}`, { method: "DELETE" });
zeigeMeldung(`Dienst '${s.name}' wurde gelöscht.`, "info");
await ladeDienste();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
function oeffneNeuerDienstDialog() {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const nameFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. Webhook-Empfänger"
});
const typFeld = erstelleElement("input", {
type: "text",
required: true,
placeholder: "z.B. HTTP, TCP, Socket"
});
const portFeld = erstelleElement("input", {
type: "number",
min: "1",
max: "65535",
placeholder: "z.B. 8081 (optional)"
});
const descFeld = erstelleElement("textarea", {
rows: "2",
placeholder: "Beschreibung des Dienstes…"
});
formular.append(
erstelleElement("label", { text: "Dienstname:" }),
nameFeld,
erstelleElement("label", { text: "Diensttyp:" }),
typFeld,
erstelleElement("label", { text: "Port (optional):" }),
portFeld,
erstelleElement("label", { text: "Beschreibung:" }),
descFeld
);
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
});
const anlegenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Dienst anlegen"
});
const modal = erstelleModal("Neuen Hintergrunddienst anlegen", formular, [abbrechenBtn, anlegenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
anlegenBtn.disabled = true;
try {
await api("/services", {
method: "POST",
body: {
name: nameFeld.value.trim(),
type: typFeld.value.trim(),
port: portFeld.value ? parseInt(portFeld.value, 10) : 0,
description: descFeld.value.trim()
}
});
modal.schliessen();
zeigeMeldung(`Dienst '${nameFeld.value.trim()}' angelegt.`, "erfolg");
await ladeDienste();
} catch (err) {
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
} finally {
anlegenBtn.disabled = false;
}
});
modal.oeffnen();
}
@@ -0,0 +1,215 @@
// ClawdDotNet — Freigabestelle (Staging)
//
// Überprüfung und Genehmigung / Ablehnung von sensiblen Werkzeug-Aufrufen.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
let wartendeAufrufe = [];
export async function holeWartendeAnzahl() {
try {
const liste = await api("/staging");
return Array.isArray(liste) ? liste.length : 0;
} catch {
return 0;
}
}
export async function rendereStaging() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "staging-ansicht" });
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
kopfZeile.append(
erstelleElement("h1", { text: "Freigabestelle" }),
erstelleElement(
"button",
{
klasse: "schaltflaeche flach",
onclick: ladeFreigaben
},
erstelleIcon(PFADE.aktualisieren, 16),
erstelleElement("span", { text: " Aktualisieren" })
)
);
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
layout.append(kopfZeile, listeBereich);
container.append(layout);
await ladeFreigaben();
}
async function ladeFreigaben() {
const container = document.getElementById("staging-liste-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Freigaben…" })
);
try {
wartendeAufrufe = await api("/staging");
container.replaceChildren();
// Aktualisiere Badge in Sidebar
aktualisiereStagingBadge(wartendeAufrufe.length);
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
container.append(
erstelleKarte("Keine offenen Freigaben", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Aktuell warten keine Werkzeug-Aufrufe auf eine manuelle Bestätigung."
})
])
);
return;
}
for (const anfrage of wartendeAufrufe) {
const kopfAktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const freigebenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer klein",
onclick: () => bestaetigeFreigabe(anfrage.id)
},
erstelleIcon(PFADE.haken, 14),
erstelleElement("span", { text: " Freigeben" })
);
const ablehnenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
onclick: () => oeffneAblehnenDialog(anfrage.id)
},
erstelleIcon(PFADE.abbrechen, 14),
erstelleElement("span", { text: " Ablehnen" })
);
kopfAktionen.append(freigebenBtn, ablehnenBtn);
const datenliste = erstelleElement("dl", { klasse: "datenliste" });
datenliste.append(
erstelleElement("dt", { text: "Aufruf-ID" }),
erstelleElement("dd", { text: String(anfrage.id) }),
erstelleElement("dt", { text: "Agent" }),
erstelleElement("dd", { text: anfrage.agentId || "—" }),
erstelleElement("dt", { text: "Werkzeug" }),
erstelleElement("dd", { text: `${anfrage.toolName} → ${anfrage.methodName}` }),
erstelleElement("dt", { text: "Zeitpunkt" }),
erstelleElement("dd", { text: formatiereDatumZeit(anfrage.requestedAt) })
);
const argBox = erstelleElement("div", { klasse: "staging-argumente" });
argBox.append(erstelleElement("strong", { text: "Eingefrorene Argumente:" }));
// Strikt textContent! Argumente können Fremdtext sein
const pre = erstelleElement("pre", {
klasse: "code-block",
text: JSON.stringify(anfrage.arguments, null, 2)
});
argBox.append(pre);
const karte = erstelleKarte(`${anfrage.toolName}.${anfrage.methodName}`, [datenliste, argBox], {
aktionen: kopfAktionen
});
container.append(karte);
}
} catch (err) {
container.replaceChildren(
erstelleKarte("Freigabestelle nicht aktiv", [
erstelleElement("p", {
klasse: "gedaempft",
text: err.message || "In dieser Instanz ist keine Freigabestelle eingerichtet."
})
])
);
}
}
function aktualisiereStagingBadge(anzahl) {
const badge = document.getElementById("staging-badge");
if (!badge) return;
if (anzahl > 0) {
badge.textContent = String(anzahl);
badge.hidden = false;
} else {
badge.hidden = true;
}
}
async function bestaetigeFreigabe(id) {
try {
const res = await api(`/staging/${id}/approve`, { method: "POST" });
zeigeMeldung(`Aufruf freigegeben: ${res?.message ?? "Erfolgreich ausgeführt"}`, "erfolg");
await ladeFreigaben();
} catch (err) {
zeigeMeldung(`Freigabe fehlgeschlagen: ${err.message}`, "gefahr");
}
}
function oeffneAblehnenDialog(id) {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const grundInput = erstelleElement("input", {
type: "text",
placeholder: "Begründung für die Ablehnung an den Agenten…"
});
formular.append(
erstelleElement("label", { text: "Begründung (optional):" }),
grundInput
);
const abbrechenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
});
const ablehnenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche gefahr",
text: "Aufruf ablehnen"
});
const modal = erstelleModal("Werkzeug-Aufruf ablehnen", formular, [abbrechenBtn, ablehnenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
ablehnenBtn.disabled = true;
try {
await api(`/staging/${id}/reject`, {
method: "POST",
body: { reason: grundInput.value.trim() }
});
modal.schliessen();
zeigeMeldung("Aufruf wurde abgelehnt.", "info");
await ladeFreigaben();
} catch (err) {
zeigeMeldung(`Ablehnen fehlgeschlagen: ${err.message}`, "gefahr");
} finally {
ablehnenBtn.disabled = false;
}
});
modal.oeffnen();
}
+397
View File
@@ -0,0 +1,397 @@
// ClawdDotNet — Aufgaben & Geplante Läufe
//
// Zwei Bereiche: Taskboard (Aufgaben) und Geplante Läufe (Jobs mit Cron & Sofortausführung).
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
let aktiverReiter = "jobs"; // "jobs" oder "taskboard"
let jobsListe = [];
let entladeEreignisse = [];
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" });
// Kopf mit Reiter-Umschaltung und Aktionen
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
const titel = erstelleElement("h1", { text: "Aufgaben & Jobs" });
const reiterLeiste = erstelleElement("div", { klasse: "reiter-leiste" });
const reiterJobs = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "jobs" ? "aktiv" : ""}`,
text: "Geplante Läufe (Jobs)",
onclick: () => wechsleReiter("jobs")
});
const reiterTaskboard = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "taskboard" ? "aktiv" : ""}`,
text: "Taskboard",
onclick: () => wechsleReiter("taskboard")
});
reiterLeiste.append(reiterJobs, reiterTaskboard);
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const neuerJobBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerJobDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Job" })
);
aktionen.append(neuerJobBtn);
kopfZeile.append(titel, reiterLeiste, aktionen);
const inhaltBereich = erstelleElement("div", { id: "aufgaben-bereich-inhalt" });
layout.append(kopfZeile, inhaltBereich);
container.append(layout);
await ladeAktivenBereich();
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Jobs" && aktiverReiter === "jobs") {
ladeAktivenBereich();
}
})
);
}
function wechsleReiter(reiter) {
aktiverReiter = reiter;
document.querySelectorAll(".reiter-schaltflaeche").forEach((r) => r.classList.remove("aktiv"));
if (reiter === "jobs") {
document.querySelector(".reiter-schaltflaeche:first-child")?.classList.add("aktiv");
} else {
document.querySelector(".reiter-schaltflaeche:last-child")?.classList.add("aktiv");
}
ladeAktivenBereich();
}
async function ladeAktivenBereich() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
container.replaceChildren();
if (aktiverReiter === "jobs") {
await rendereJobs(container);
} else {
await rendereTaskboard(container);
}
}
// ─── Jobs / Geplante Läufe ───
async function rendereJobs(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade geplante Läufe…" })
);
try {
jobsListe = await api("/jobs");
container.replaceChildren();
if (!jobsListe || jobsListe.length === 0) {
container.append(
erstelleKarte("Keine geplanten Läufe", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Es sind noch keine regelmäßigen Agenten-Läufe oder Tool-Jobs eingerichtet."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
const trKopf = erstelleElement("tr");
trKopf.append(
erstelleElement("th", { text: "Status" }),
erstelleElement("th", { text: "Kennung / Art" }),
erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Cron-Muster" }),
erstelleElement("th", { text: "Nächster Lauf (UTC)" }),
erstelleElement("th", { text: "Letzter Lauf (Lokal)" }),
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
);
thead.append(trKopf);
const tbody = erstelleElement("tbody");
for (const job of jobsListe) {
const tr = erstelleElement("tr");
// Schalter Ein/Aus
const tdStatus = erstelleElement("td");
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
const schalterInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(job.enabled),
onchange: async (e) => {
const neuAktiv = e.target.checked;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}/enabled?agentId=${encodeURIComponent(job.agentId)}&enabled=${neuAktiv}`,
{ method: "PUT" }
);
job.enabled = neuAktiv;
zeigeMeldung(`Job '${job.jobId}' ${neuAktiv ? "aktiviert" : "pausiert"}.`, "info");
} catch (err) {
e.target.checked = !neuAktiv;
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
});
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
tdStatus.append(schalterLabel);
// Name / Art
const tdName = erstelleElement("td");
tdName.append(
erstelleElement("strong", { text: job.jobId }),
erstelleElement("div", {
klasse: "gedaempft text-klein",
text: job.kind === "toolJob" ? `Werkzeug (${job.toolName || ""})` : "Agent-Aufruf"
})
);
// Agent
const tdAgent = erstelleElement("td", { text: job.agentId });
// Cron
const tdCron = erstelleElement("td");
tdCron.append(erstelleElement("code", { text: job.cron }));
// Zeiten
const tdNext = erstelleElement("td", {
text: job.nextRunUtc ? formatiereDatumZeit(job.nextRunUtc) : "—"
});
const tdLast = erstelleElement("td", {
text: job.lastRunLocal ? formatiereDatumZeit(job.lastRunLocal) : "—"
});
// Aktionen (Sofortlauf, Löschen)
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal", stil: "justify-content: flex-end;" });
const ausfuehrenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
title: "Job jetzt sofort manuell ausführen",
onclick: () => fuehreJobAus(job.jobId)
},
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Start" })
);
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
title: "Job löschen",
onclick: () => loescheJob(job)
},
erstelleIcon(PFADE.muelleimer, 14)
);
aktionenGruppe.append(ausfuehrenBtn, loeschenBtn);
tdAktionen.append(aktionenGruppe);
tr.append(tdStatus, tdName, tdAgent, tdCron, tdNext, tdLast, tdAktionen);
tbody.append(tr);
}
tabelle.append(thead, tbody);
container.append(tabelle);
} catch (err) {
container.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function fuehreJobAus(jobId) {
try {
const res = await api(`/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST" });
zeigeMeldung(`Job '${jobId}' angestoßen: ${res?.message ?? "Erfolgreich"}`, "erfolg");
} catch (err) {
zeigeMeldung(`Ausführung fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function loescheJob(job) {
if (!confirm(`Soll der geplante Job '${job.jobId}' gelöscht werden?`)) return;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}?agentId=${encodeURIComponent(job.agentId)}&kind=${encodeURIComponent(job.kind)}`,
{ method: "DELETE" }
);
zeigeMeldung(`Job '${job.jobId}' wurde gelöscht.`, "info");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function oeffneNeuerJobDialog() {
let agenten = [];
try {
agenten = await api("/agents");
} catch {}
const formular = erstelleElement("form", { klasse: "formular-raster" });
const agentSelect = erstelleElement("select", { required: true });
for (const a of agenten) {
agentSelect.append(erstelleElement("option", { value: a.agentId, text: `${a.displayName} (${a.agentId})` }));
}
const kindSelect = erstelleElement("select");
kindSelect.append(
erstelleElement("option", { value: "agentWakeup", text: "Agent Wakeup (Regelmäßiger Aufruf)" }),
erstelleElement("option", { value: "toolJob", text: "Tool Job (Werkzeug-Ausführung)" })
);
const cronInput = erstelleElement("input", {
type: "text",
required: true,
value: "0 * * * *",
placeholder: "z.B. 0 * * * * (stündlich)"
});
const taskMsgInput = erstelleElement("textarea", {
rows: "2",
placeholder: "Optionale Start-Nachricht / Anweisung an den Agenten…"
});
formular.append(
erstelleElement("label", { text: "Ziel-Agent:" }),
agentSelect,
erstelleElement("label", { text: "Art des Jobs:" }),
kindSelect,
erstelleElement("label", { text: "Cron-Ausdruck:" }),
cronInput,
erstelleElement("label", { text: "Aufgaben-Nachricht:" }),
taskMsgInput
);
const abbrechenBtn = erstelleElement(
"button",
{
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
}
);
const anlegenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Job erstellen"
});
const modal = erstelleModal("Neuen geplanten Job anlegen", formular, [abbrechenBtn, anlegenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
anlegenBtn.disabled = true;
try {
await api("/jobs", {
method: "POST",
body: {
agentId: agentSelect.value,
kind: kindSelect.value,
cron: cronInput.value.trim(),
taskMessage: taskMsgInput.value.trim(),
runOnStart: false
}
});
modal.schliessen();
zeigeMeldung("Geplanter Job wurde erstellt.", "erfolg");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Fehler beim Erstellen: ${err.message}`, "gefahr");
} finally {
anlegenBtn.disabled = false;
}
});
modal.oeffnen();
}
// ─── Taskboard ───
async function rendereTaskboard(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Taskboard…" })
);
try {
const tasks = await api("/tasks?limit=50");
container.replaceChildren();
if (!tasks || tasks.length === 0) {
container.append(
erstelleKarte("Taskboard leer", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Aktuell liegen keine offenen Aufgaben im gemeinsamen Arbeitsbereich."
})
])
);
return;
}
const raster = erstelleElement("div", { klasse: "taskboard-raster" });
for (const task of tasks) {
const karte = erstelleKarte(task.title || `Aufgabe ${task.id}`, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: `ID: ${task.id} · Agent: ${task.agentId || "—"}` }),
erstelleElement("div", {
klasse: "task-status-zeile",
stil: "margin-top: 8px;"
}, erstellePlakette(task.status || "Offen", task.status === "Done" ? "erfolg" : "neutral")),
erstelleElement("div", {
klasse: "gedaempft text-klein",
stil: "margin-top: 8px;",
text: `Erstellt: ${formatiereDatumZeit(task.createdAt)}`
})
]);
raster.append(karte);
}
container.append(raster);
} catch (err) {
container.replaceChildren(
erstelleKarte("Taskboard nicht verfügbar", [
erstelleElement("p", {
klasse: "gedaempft",
text: err.message || "In dieser Instanz ist kein Taskboard aktiv (kein gemeinsamer Arbeitsbereich)."
})
])
);
}
}
+176
View File
@@ -0,0 +1,176 @@
// ClawdDotNet — Token-Verbrauch & Analyse
//
// Diagramme (CSS/DOM-Balken ohne externe Bibliothek), Statistiken und Läufetabelle.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
formatiereDatumZeit,
formatiereTokens,
formatiereKosten
} from "../modules/dom.js";
export async function rendereVerbrauch() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
layout.append(erstelleElement("h1", { text: "Token-Verbrauch & Analyse" }));
const inhaltBereich = erstelleElement("div", { id: "verbrauch-inhalt-bereich" });
layout.append(inhaltBereich);
container.append(layout);
await ladeVerbrauch();
}
async function ladeVerbrauch() {
const container = document.getElementById("verbrauch-inhalt-bereich");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Verbrauchsdaten…" })
);
try {
const laeufe = await api("/usage?limit=100");
container.replaceChildren();
if (!laeufe || laeufe.length === 0) {
container.append(
erstelleKarte("Keine Daten", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Noch keine Token-Verbrauchsdaten aufgezeichnet."
})
])
);
return;
}
// Statistiken berechnen
let summeTokens = 0;
let summePrompt = 0;
let summeCompletion = 0;
let summeKosten = 0;
const nachAgent = new Map();
const nachModell = new Map();
for (const l of laeufe) {
summeTokens += l.totalTokens || 0;
summePrompt += l.promptTokens || 0;
summeCompletion += l.completionTokens || 0;
summeKosten += Number(l.cost || 0);
const agentKey = l.agentId || "Unbekannt";
nachAgent.set(agentKey, (nachAgent.get(agentKey) || 0) + (l.totalTokens || 0));
const modellKey = l.model || "Unbekannt";
nachModell.set(modellKey, (nachModell.get(modellKey) || 0) + (l.totalTokens || 0));
}
// 1. Kacheln (Statistiken)
const kachelGitter = erstelleElement("div", { klasse: "kachel-gitter" });
kachelGitter.append(
erstelleStatKachel("Gesamtkosten (100 Läufe)", formatiereKosten(summeKosten)),
erstelleStatKachel("Tokens gesamt", formatiereTokens(summeTokens)),
erstelleStatKachel("Eingabe-Tokens (Prompt)", formatiereTokens(summePrompt)),
erstelleStatKachel("Ausgabe-Tokens (Completion)", formatiereTokens(summeCompletion))
);
// 2. Balkendiagramme (rein CSS/DOM)
const diagrammGitter = erstelleElement("div", { klasse: "diagramm-gitter" });
const agentBalken = erstelleBalkenDiagramm("Tokens nach Agent", nachAgent, summeTokens);
const modellBalken = erstelleBalkenDiagramm("Tokens nach Modell", nachModell, summeTokens);
diagrammGitter.append(agentBalken, modellBalken);
// 3. Läufe-Tabelle
const tabelleKarte = erstelleElement("div", { klasse: "karte" });
tabelleKarte.append(erstelleElement("h2", { text: "Letzte Agenten-Läufe" }));
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
thead.append(
erstelleElement("tr", {},
erstelleElement("th", { text: "Zeitpunkt" }),
erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Modell" }),
erstelleElement("th", { text: "Prompt", stil: "text-align: right;" }),
erstelleElement("th", { text: "Completion", stil: "text-align: right;" }),
erstelleElement("th", { text: "Gesamt", stil: "text-align: right;" }),
erstelleElement("th", { text: "Kosten", stil: "text-align: right;" })
)
);
const tbody = erstelleElement("tbody");
for (const lauf of laeufe) {
const tr = erstelleElement("tr");
tr.append(
erstelleElement("td", { text: formatiereDatumZeit(lauf.timestamp) }),
erstelleElement("td", {}, erstelleElement("strong", { text: lauf.agentId })),
erstelleElement("td", { klasse: "gedaempft text-klein", text: lauf.model }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.promptTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.completionTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.totalTokens) }),
erstelleElement("td", { stil: "text-align: right;", text: formatiereKosten(lauf.cost) })
);
tbody.append(tr);
}
tabelle.append(thead, tbody);
tabelleKarte.append(tabelle);
container.append(kachelGitter, diagrammGitter, tabelleKarte);
} catch (err) {
container.replaceChildren(
erstelleKarte("Verbrauchsdaten nicht verfügbar", [
erstelleElement("p", { klasse: "gedaempft", text: err.message })
])
);
}
}
function erstelleStatKachel(titel, wert) {
const kachel = erstelleElement("div", { klasse: "stat-kachel karte" });
kachel.append(
erstelleElement("div", { klasse: "stat-titel gedaempft", text: titel }),
erstelleElement("div", { klasse: "stat-wert", text: wert })
);
return kachel;
}
function erstelleBalkenDiagramm(titel, datenMap, gesamt) {
const karte = erstelleKarte(titel);
const container = erstelleElement("div", { klasse: "balken-liste" });
const sortiert = [...datenMap.entries()].sort((a, b) => b[1] - a[1]);
for (const [name, wert] of sortiert) {
const prozent = gesamt > 0 ? ((wert / gesamt) * 100).toFixed(1) : "0";
const zeile = erstelleElement("div", { klasse: "balken-zeile" });
const meta = erstelleElement("div", { klasse: "balken-meta" });
meta.append(
erstelleElement("span", { klasse: "balken-name", text: name }),
erstelleElement("span", { klasse: "balken-wert gedaempft", text: `${formatiereTokens(wert)} (${prozent}%)` })
);
const spur = erstelleElement("div", { klasse: "balken-spur" });
const fuellung = erstelleElement("div", {
klasse: "balken-fuellung",
stil: `width: ${Math.max(1, Number(prozent))}%;`
});
spur.append(fuellung);
zeile.append(meta, spur);
container.append(zeile);
}
karte.append(container);
return karte;
}