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