Initial commit: ClawdDotNet

Import des bestehenden Projektstands in Git.
- .NET 10 WinForms Anwendung (Multi-Agent / Tool-System)
- .gitignore fuer Build-Artefakte, Secrets und Runtime-Daten ergaenzt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-26 18:21:46 +02:00
co-authored by Claude Opus 4.8
commit 2fed388c99
154 changed files with 29736 additions and 0 deletions
@@ -0,0 +1,10 @@
---
trigger: always_on
---
Achtung: Die Entwicklung dieses Projekts ist strikt in "Core" und "Tools" getrennt!
Die Entwicklung des Cores übernimmt ein anderer Agent. Du nimmst unter keinen Umständen änderungen am Core vor!
Du bist nur dafür zuständig neue Tools für dieses Projekt zu entwickeln.
Informationen wie das Tool aufgebaut sein mus findest du im /docs/ToolDevelopmentGuide.md
Falls du ein Tool aufgrund fehlender schnittstellen nicht realisieren kannst gibst du mir eine entsprechende Rückmeldung!
Du wirst auf keinen Fall selbstständig das Core-Tool Interface verändern oder bearbeiten.
+46
View File
@@ -0,0 +1,46 @@
# ---- Build results ----
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# ---- Visual Studio / Rider ----
.vs/
.idea/
*.user
*.suo
*.userosscache
*.sln.docstates
*.userprefs
# ---- Build / restore artifacts ----
project.lock.json
project.fragment.lock.json
artifacts/
*.nupkg
*.snupkg
msbuild.log
msbuild.err
msbuild.wrn
# ---- Deploy / packaging output ----
deploy/*.zip
# ---- Runtime instance/agent data (created next to the binary) ----
Instances/
data/
# ---- OS files ----
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
.DS_Store
# ---- Secrets / local-only settings ----
# Local Claude Code settings contain machine-specific permissions and API keys
.claude/settings.local.json
# Real credential configs (commit *.template / placeholder configs only)
*.secrets.json
appsettings.*.local.json
+105
View File
@@ -0,0 +1,105 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet</RootNamespace>
<ApplicationIcon>Martin-Berube-Flat-Animal-Crab.ico</ApplicationIcon>
<!-- ── Sauberes Build-Verzeichnis ── -->
<!-- Nur deutsche + englische Satelliten-Assemblies (Sprachpakete) ausgeben -->
<SatelliteResourceLanguages>de;en</SatelliteResourceLanguages>
<!-- Keine XML-Dokumentationsdateien von NuGet-Paketen kopieren -->
<PublishDocumentationFiles>false</PublishDocumentationFiles>
<!-- PDB-Dateien nur im Debug-Build ausgeben -->
<DebugSymbols Condition="'$(Configuration)' == 'Release'">false</DebugSymbols>
<CopyOutputSymbolsToOutputDirectory Condition="'$(Configuration)' == 'Release'">false</CopyOutputSymbolsToOutputDirectory>
</PropertyGroup>
<ItemGroup>
<Compile Remove="src\**" />
<None Remove="src\**" />
<EmbeddedResource Remove="src\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="EmbeddedUI\**\*" />
</ItemGroup>
<ItemGroup>
<Content Include="Martin-Berube-Flat-Animal-Crab.ico" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="src\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.Telegram\ClawdDotNet.Tools.Telegram.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.Mail\ClawdDotNet.Tools.Mail.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.Database\ClawdDotNet.Tools.Database.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.FTP\ClawdDotNet.Tools.FTP.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.WebMonitor\ClawdDotNet.Tools.WebMonitor.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.AgentComm\ClawdDotNet.Tools.AgentComm.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.AgentSpawn\ClawdDotNet.Tools.AgentSpawn.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.AgentEditor\ClawdDotNet.Tools.AgentEditor.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<!-- ═══════════════════════════════════════════════
Sauberes Programmverzeichnis
═══════════════════════════════════════════════ -->
<!-- XML-Dokumentation von NuGet-Paketen nicht ins Build-Verzeichnis kopieren -->
<Target Name="RemoveNuGetXmlDocs" AfterTargets="ResolveReferences">
<ItemGroup>
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)"
Condition="'%(Extension)' == '.xml'" />
</ItemGroup>
</Target>
<!-- WebView2-WPF-Assemblies entfernen (nur WinForms wird verwendet) -->
<Target Name="RemoveWebView2Wpf" AfterTargets="ResolveReferences">
<ItemGroup>
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)"
Condition="$([System.String]::Copy('%(Filename)').Contains('WebView2.Wpf'))" />
</ItemGroup>
</Target>
<!-- Arbeitsordner (tools/, Logs/, Instances/) anlegen -->
<Target Name="CreateWorkingDirectories" AfterTargets="Build">
<MakeDir Directories="$(OutputPath)tools" />
<MakeDir Directories="$(OutputPath)Logs" />
<MakeDir Directories="$(OutputPath)Instances" />
</Target>
<Target Name="CreatePublishDirectories" AfterTargets="Publish">
<MakeDir Directories="$(PublishDir)tools" />
<MakeDir Directories="$(PublishDir)Logs" />
<MakeDir Directories="$(PublishDir)Instances" />
</Target>
</Project>
+15
View File
@@ -0,0 +1,15 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ClawdDotNet.Core/ClawdDotNet.Core.csproj" />
<Project Path="src/ClawdDotNet.Tools.DirectAPI/ClawdDotNet.Tools.DirectAPI.csproj" />
<Project Path="src/ClawdDotNet.Tools.FileRW/ClawdDotNet.Tools.FileRW.csproj" />
<Project Path="src/ClawdDotNet.Tools.Telegram/ClawdDotNet.Tools.Telegram.csproj" />
<Project Path="src/ClawdDotNet.Tools.Mail/ClawdDotNet.Tools.Mail.csproj" />
<Project Path="src/ClawdDotNet.Tools.Database/ClawdDotNet.Tools.Database.csproj" />
<Project Path="src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj" />
<Project Path="src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj" />
<Project Path="src/ClawdDotNet.Tools.WebMonitor/ClawdDotNet.Tools.WebMonitor.csproj" />
<Project Path="src/ClawdDotNet.Tools.TelegramClient/ClawdDotNet.Tools.TelegramClient.csproj" />
</Folder>
<Project Path="ClawdDotNet.csproj" />
</Solution>
+695
View File
@@ -0,0 +1,695 @@
# ClawdDotNet Prompt-Anhang: Internet-Tools
Dieser Abschnitt ergänzt die bestehenden Prompt-Anhänge und definiert drei
unabhängige Internet-Tools sowie ein spezialisiertes Monitoring-Tool für
strukturierte Webseiten (z.B. Capitol Trades).
Alle Tools folgen den bekannten Prinzipien: IAgentTool implementiert,
Konfiguration ausschließlich aus AgentToolContext, keine Tool-zu-Tool-Abhängigkeiten.
---
## Pflicht-Regel: Timestamp in jedem ToolResult
**Diese Regel gilt für ALLE Internet-Tools ohne Ausnahme.**
Jedes `ToolResult.Content` das Finanzdaten, Nachrichten oder externe Daten enthält,
muss ein JSON-Objekt zurückgeben das mindestens enthält:
```json
{
"fetchedAt": "2026-05-13T07:42:00Z",
"dataAsOf": "2026-05-13T07:40:00Z",
"source": "https://...",
"data": { ... }
}
```
- `fetchedAt` = Zeitpunkt des HTTP-Requests (UTC, immer `DateTime.UtcNow`)
- `dataAsOf` = Zeitpunkt der Daten laut Quelle (aus Response-Header, HTML oder API-Feld)
Falls nicht ermittelbar: `null` — NIEMALS schätzen oder weglassen
- `source` = exakte URL die abgerufen wurde
Der System-Prompt jedes Agenten der Internet-Tools nutzt MUSS enthalten:
> "Verwende niemals Daten ohne `fetchedAt`-Feld. Wenn `dataAsOf` null ist,
> teile dem Nutzer mit dass der Datenzeitpunkt unbekannt ist.
> Erfinde niemals Kurse, Preise oder Daten aus dem Gedächtnis."
---
## Tool 1: DirectAPI — Echtzeit-Finanzdaten
**Datei: `ClawdDotNet.Tools.DirectAPI/DirectApiTool.cs`**
Direkter HTTP-Zugriff auf Finanz-APIs. Kein HTML-Parsing.
Strukturiertes JSON mit verifizierbaren Timestamps.
### AgentConfig-Beispiel
```json
"DirectAPI": {
"providers": {
"twelvedata": {
"apiKey": "your-key-here",
"baseUrl": "https://api.twelvedata.com"
},
"alphavantage": {
"apiKey": "your-key-here",
"baseUrl": "https://www.alphavantage.co"
},
"coingecko": {
"baseUrl": "https://api.coingecko.com/api/v3"
},
"yahoo": {
"baseUrl": "https://query1.finance.yahoo.com"
}
},
"defaultProvider": "twelvedata",
"cacheTtlSeconds": 60
}
```
### Tool-Implementierung
```csharp
namespace ClawdDotNet.Tools.DirectAPI;
public sealed class DirectApiTool : IAgentTool
{
public string Name => "DirectAPI";
public string Description => """
Ruft Echtzeit-Finanzdaten von verifizierten APIs ab.
Alle Antworten enthalten fetchedAt und dataAsOf Timestamps.
Aktionen: quote, history, crypto, forex, search
""";
public JsonElement InputSchema => JsonDocument.Parse("""
{
"type": "object",
"required": ["action", "symbol"],
"properties": {
"action": {
"type": "string",
"enum": ["quote", "history", "crypto", "forex", "search"],
"description": "quote=aktueller Kurs, history=Kursverlauf, crypto=Krypto, forex=Wechselkurs, search=Symbol suchen"
},
"symbol": { "type": "string", "description": "z.B. NVDA, BTC, EUR/USD" },
"provider": { "type": "string", "description": "optional: twelvedata|alphavantage|coingecko|yahoo" },
"interval": { "type": "string", "description": "für history: 1min|5min|1h|1day" },
"outputsize":{ "type": "integer","description": "für history: Anzahl Datenpunkte, max 500" }
}
}
""").RootElement;
public async Task<ToolResult> ExecuteAsync(
JsonElement input, AgentToolContext ctx, CancellationToken ct)
{
// Config aus Context lesen — nie aus statischen Feldern
var config = ctx.ToolConfig["DirectAPI"] as Dictionary<string, object?>
?? throw new InvalidOperationException("DirectAPI config missing");
var providers = config["providers"] as Dictionary<string, object?> ?? new();
var cacheTtl = Convert.ToInt32(config.GetValueOrDefault("cacheTtlSeconds") ?? 60);
var action = input.GetProperty("action").GetString()!;
var symbol = input.GetProperty("symbol").GetString()!;
var provider = input.TryGetProperty("provider", out var p)
? p.GetString()
: config.GetValueOrDefault("defaultProvider")?.ToString()
?? "twelvedata";
// Cache-Check: agentId + symbol + action als Key
var cacheKey = $"directapi:{ctx.AgentId}:{provider}:{action}:{symbol}";
// (Cache-Implementierung über IMemoryCache oder Redis aus Core)
return action switch
{
"quote" => await FetchQuoteAsync(symbol, provider, providers, ct),
"history" => await FetchHistoryAsync(input, symbol, provider, providers, ct),
"crypto" => await FetchCryptoAsync(symbol, providers, ct),
"forex" => await FetchForexAsync(symbol, provider, providers, ct),
"search" => await SearchSymbolAsync(symbol, provider, providers, ct),
_ => new ToolResult(false, "", $"Unknown action: {action}")
};
}
private async Task<ToolResult> FetchQuoteAsync(
string symbol, string provider,
Dictionary<string, object?> providers, CancellationToken ct)
{
// Jeder Provider hat eigene URL-Struktur
// Gemeinsam: immer Cache-Control: no-cache Header setzen
// Gemeinsam: dataAsOf aus Response extrahieren (nicht schätzen)
// Twelve Data Quote:
// GET https://api.twelvedata.com/quote?symbol={symbol}&apikey={key}
// Response enthält: "datetime" → das ist dataAsOf
// Response enthält: "timestamp" (Unix) → ebenfalls verwertbar
// Yahoo Finance Quote (kein Key nötig):
// GET https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1m&range=1d
// Response: result[0].meta.regularMarketTime (Unix timestamp) → dataAsOf
// Alpha Vantage Quote:
// GET https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={key}
// Response: "Global Quote"."07. latest trading day" → dataAsOf (nur Datum, keine Zeit)
// IMPLEMENTIERUNGSREGEL: dataAsOf IMMER aus der API-Antwort lesen.
// Wenn das Feld fehlt oder leer ist → dataAsOf = null, NICHT DateTime.UtcNow.
throw new NotImplementedException("Implement per provider");
}
// history, crypto, forex, search analog implementieren
}
```
### NuGet
Keine externen HTTP-Bibliotheken. Nur `System.Net.Http.HttpClient` via `IHttpClientFactory`.
---
## Tool 2: WebFetch — Nachrichten & strukturiertes HTML
**Datei: `ClawdDotNet.Tools.WebFetch/WebFetchTool.cs`**
HTTP-Abruf mit Whitelist, Timestamp-Extraktion und HTML-zu-Text-Konvertierung.
Kein JavaScript-Rendering (statisches HTML only).
### AgentConfig-Beispiel
```json
"WebFetch": {
"allowedDomains": [
"reuters.com",
"bloomberg.com",
"sec.gov",
"feeds.finance.yahoo.com",
"reddit.com",
"capitoltrades.com"
],
"maxResponseKb": 512,
"timeoutSeconds": 15,
"userAgent": "ClawdDotNet-Agent/1.0 (Research Bot)"
}
```
### Tool-Implementierung
```csharp
namespace ClawdDotNet.Tools.WebFetch;
public sealed class WebFetchTool : IAgentTool
{
public string Name => "WebFetch";
public string Description => """
Ruft statische Webseiten oder RSS-Feeds ab und extrahiert Text + Timestamps.
Nur Domains aus der Whitelist erlaubt. Kein JavaScript-Rendering.
Aktionen: fetch, rss
""";
public JsonElement InputSchema => JsonDocument.Parse("""
{
"type": "object",
"required": ["action", "url"],
"properties": {
"action": {
"type": "string",
"enum": ["fetch", "rss"],
"description": "fetch=HTML-Seite abrufen und zu Text konvertieren, rss=RSS/Atom-Feed parsen"
},
"url": { "type": "string" },
"selector":{ "type": "string",
"description": "optional: CSS-ähnlicher Hint welcher Teil relevant ist, z.B. 'table', 'article'" }
}
}
""").RootElement;
public async Task<ToolResult> ExecuteAsync(
JsonElement input, AgentToolContext ctx, CancellationToken ct)
{
var config = ctx.ToolConfig["WebFetch"] as Dictionary<string, object?> ?? new();
var allowedDomains = (config.GetValueOrDefault("allowedDomains")
as List<string>) ?? new List<string>();
var url = input.GetProperty("url").GetString()!;
var action = input.GetProperty("action").GetString()!;
// Domain-Whitelist prüfen
var host = new Uri(url).Host.Replace("www.", "");
if (!allowedDomains.Any(d => host == d || host.EndsWith("." + d)))
return new ToolResult(false, "",
$"Domain '{host}' nicht in der Whitelist dieses Agenten.");
return action switch
{
"fetch" => await FetchPageAsync(url, input, config, ct),
"rss" => await FetchRssAsync(url, ct),
_ => new ToolResult(false, "", $"Unknown action: {action}")
};
}
private async Task<ToolResult> FetchPageAsync(
string url, JsonElement input,
Dictionary<string, object?> config, CancellationToken ct)
{
using var http = CreateHttpClient(config);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Kein Cache — immer frische Daten anfordern
request.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue
{ NoCache = true, NoStore = true };
using var response = await http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
// dataAsOf aus HTTP-Headern extrahieren (Reihenfolge: Last-Modified > Date)
DateTimeOffset? dataAsOf = response.Content.Headers.LastModified
?? response.Headers.Date;
var html = await response.Content.ReadAsStringAsync(ct);
var maxKb = Convert.ToInt32(config.GetValueOrDefault("maxResponseKb") ?? 512);
if (html.Length > maxKb * 1024)
html = html[..(maxKb * 1024)];
// HTML → lesbarer Text (einfache Implementierung ohne externe Libs)
var text = StripHtml(html);
// Versuche dataAsOf aus HTML-Meta-Tags zu verfeinern falls Header fehlt
if (dataAsOf == null)
dataAsOf = ExtractDateFromHtml(html);
var result = new
{
fetchedAt = DateTime.UtcNow,
dataAsOf = dataAsOf?.UtcDateTime,
source = url,
data = new { text }
};
return new ToolResult(true, JsonSerializer.Serialize(result));
}
private async Task<ToolResult> FetchRssAsync(string url, CancellationToken ct)
{
// XML parsen mit System.Xml.Linq
// Einträge: title, link, pubDate (→ dataAsOf), description
// Neueste Einträge zuerst, max 20
throw new NotImplementedException();
}
private static string StripHtml(string html)
{
// Einfaches Regex-basiertes Stripping
// Script- und Style-Tags zuerst entfernen, dann alle anderen Tags
// Anschließend HTML-Entities dekodieren (System.Net.WebUtility.HtmlDecode)
// Mehrfache Leerzeilen auf max. 2 reduzieren
throw new NotImplementedException();
}
private static DateTime? ExtractDateFromHtml(string html)
{
// Suche nach: <time datetime="...">, og:article:published_time,
// datePublished JSON-LD, <meta name="date" content="...">
throw new NotImplementedException();
}
private static HttpClient CreateHttpClient(Dictionary<string, object?> config)
{
var client = new HttpClient();
var timeout = Convert.ToInt32(config.GetValueOrDefault("timeoutSeconds") ?? 15);
client.Timeout = TimeSpan.FromSeconds(timeout);
client.DefaultRequestHeaders.UserAgent.ParseAdd(
config.GetValueOrDefault("userAgent")?.ToString()
?? "ClawdDotNet-Agent/1.0");
return client;
}
}
```
### NuGet
`System.Xml.Linq` (im SDK enthalten) für RSS-Parsing. Kein externes HTML-Parser-Paket nötig.
---
## Tool 3: WebMonitor — Strukturiertes Seiten-Monitoring
**Datei: `ClawdDotNet.Tools.WebMonitor/WebMonitorTool.cs`**
Spezialisiert auf wiederkehrende Überprüfung von Seiten auf **neue Einträge**.
Speichert den zuletzt gesehenen Stand in der Datenbank und liefert nur Deltas.
Primärer Anwendungsfall: Capitol Trades, SEC-Filings, jede tabellarische Seite
mit eindeutigen IDs oder fortlaufenden Einträgen.
### Wie Capitol Trades funktioniert
Die Seite `https://www.capitoltrades.com/trades?pageSize=96` liefert:
- Sauber strukturiertes HTML mit einer Tabelle
- Jeder Trade hat eine eindeutige Trade-ID in der Detail-URL: `/trades/20003797558`
- Trade-IDs sind fortlaufend und numerisch aufsteigend
- Kein JavaScript-Rendering nötig — Daten sind im initialen HTML
Strategie: Höchste bekannte Trade-ID als Anker speichern. Bei jedem Check:
alle IDs auf Seite 1 extrahieren, mit gespeicherter Max-ID vergleichen,
nur neue Einträge melden.
### AgentConfig-Beispiel
```json
"WebMonitor": {
"monitors": {
"capitol_trades": {
"url": "https://www.capitoltrades.com/trades?pageSize=96",
"checkIntervalMinutes": 30,
"idPattern": "/trades/(\\d+)",
"idField": "tradeId",
"parser": "capitol_trades",
"alertOnNew": true,
"storeHistory": true
},
"sec_filings": {
"url": "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=4&dateb=&owner=include&count=40",
"checkIntervalMinutes": 60,
"idPattern": "CIK=(\\d+)",
"parser": "sec_form4",
"alertOnNew": true,
"storeHistory": false
}
}
}
```
### Tool-Implementierung
```csharp
namespace ClawdDotNet.Tools.WebMonitor;
public sealed class WebMonitorTool : IAgentTool
{
public string Name => "WebMonitor";
public string Description => """
Überwacht Webseiten auf neue Einträge und liefert nur die Deltas seit dem letzten Check.
Speichert den Stand in der Datenbank. Ideal für Capitol Trades, SEC-Filings, etc.
Aktionen: check, history, status
""";
public JsonElement InputSchema => JsonDocument.Parse("""
{
"type": "object",
"required": ["action", "monitorId"],
"properties": {
"action": {
"type": "string",
"enum": ["check", "history", "status"],
"description": "check=jetzt prüfen und Deltas liefern, history=bisherige Einträge, status=letzter Check-Zeitpunkt"
},
"monitorId": {
"type": "string",
"description": "z.B. capitol_trades, sec_filings — muss in Config definiert sein"
},
"limit": {
"type": "integer",
"description": "max. Anzahl Einträge für history, default 50"
}
}
}
""").RootElement;
public async Task<ToolResult> ExecuteAsync(
JsonElement input, AgentToolContext ctx, CancellationToken ct)
{
var config = ctx.ToolConfig["WebMonitor"] as Dictionary<string, object?> ?? new();
var monitors = config["monitors"] as Dictionary<string, object?> ?? new();
var action = input.GetProperty("action").GetString()!;
var monitorId = input.GetProperty("monitorId").GetString()!;
if (!monitors.ContainsKey(monitorId))
return new ToolResult(false, "",
$"Monitor '{monitorId}' nicht in der Config dieses Agenten definiert.");
var monitorConfig = monitors[monitorId] as Dictionary<string, object?> ?? new();
return action switch
{
"check" => await CheckForNewEntriesAsync(monitorId, monitorConfig, ctx, ct),
"history" => await GetHistoryAsync(monitorId, input, ctx, ct),
"status" => await GetStatusAsync(monitorId, ctx, ct),
_ => new ToolResult(false, "", $"Unknown action: {action}")
};
}
private async Task<ToolResult> CheckForNewEntriesAsync(
string monitorId, Dictionary<string, object?> monitorConfig,
AgentToolContext ctx, CancellationToken ct)
{
var url = monitorConfig["url"]?.ToString()!;
var parser = monitorConfig["parser"]?.ToString() ?? "generic";
var idPattern = monitorConfig["idPattern"]?.ToString();
// 1. Seite abrufen
using var http = new HttpClient();
http.DefaultRequestHeaders.CacheControl =
new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true };
var html = await http.GetStringAsync(url, ct);
var fetchedAt = DateTime.UtcNow;
// 2. Einträge parsen (parser-spezifisch)
var entries = parser switch
{
"capitol_trades" => ParseCapitolTrades(html),
"sec_form4" => ParseSecForm4(html),
_ => ParseGeneric(html, idPattern)
};
// 3. Letzte bekannte Max-ID aus State laden
// State-Key: "webmonitor:{agentId}:{monitorId}:maxId"
// State-Speicher: über den StateManager aus dem Core
// (wird als Dependency über AgentToolContext injiziert — siehe Core-Erweiterung unten)
var stateKey = $"webmonitor:{ctx.AgentId}:{monitorId}:maxId";
var lastMaxId = await ctx.StateStore.GetAsync(stateKey, ct); // string? → long?
var lastKnown = long.TryParse(lastMaxId, out var l) ? l : 0L;
// 4. Neue Einträge = alle mit ID > lastKnown
var newEntries = entries
.Where(e => e.NumericId > lastKnown)
.OrderBy(e => e.NumericId)
.ToList();
// 5. Neue Max-ID persistieren
if (newEntries.Count > 0)
{
var newMax = newEntries.Max(e => e.NumericId).ToString();
await ctx.StateStore.SetAsync(stateKey, newMax, ct);
// Optional: Einträge in History-Tabelle speichern
if (monitorConfig.GetValueOrDefault("storeHistory") is true)
await StoreHistoryAsync(monitorId, newEntries, ctx, ct);
}
// 6. Letzten Check-Zeitpunkt aktualisieren
await ctx.StateStore.SetAsync(
$"webmonitor:{ctx.AgentId}:{monitorId}:lastCheck",
fetchedAt.ToString("O"), ct);
// 7. Ergebnis
var result = new
{
fetchedAt = fetchedAt,
dataAsOf = fetchedAt, // Capitol Trades: Seite ist immer aktuell
source = url,
monitorId = monitorId,
newCount = newEntries.Count,
data = new
{
newEntries = newEntries,
message = newEntries.Count == 0
? "Keine neuen Einträge seit dem letzten Check."
: $"{newEntries.Count} neue Einträge gefunden."
}
};
return new ToolResult(true, JsonSerializer.Serialize(result));
}
private static List<MonitorEntry> ParseCapitolTrades(string html)
{
// HTML-Tabelle parsen mit System.Text.RegularExpressions + String-Operationen
//
// Zu extrahieren pro Zeile:
// tradeId: aus /trades/(\d+) in der Detail-URL → NumericId
// politician: Linktext des Politiker-Links
// party: "Republican" | "Democrat" aus dem Text
// chamber: "House" | "Senate"
// state: 2-Buchstaben-Code
// issuer: Unternehmensname
// ticker: z.B. "AVGO:US" → nur "AVGO"
// published: Datum "8 May 2026" → DateTime
// traded: Datum "27 Apr 2026" → DateTime
// filedAfterDays: Zahl aus "days N"
// owner: "Undisclosed" | "Spouse" | "Joint" | etc.
// tradeType: "buy" | "sell"
// size: "1K15K" | "15K50K" | etc.
// price: "$418.20" → decimal
// detailUrl: vollständige URL
// WICHTIG: Duplikate durch pageSize=96 möglich (selbe Transaktion, 2 IDs).
// Beide IDs einliefern — der Agent entscheidet ob relevant.
throw new NotImplementedException();
}
private static List<MonitorEntry> ParseSecForm4(string html)
=> throw new NotImplementedException();
private static List<MonitorEntry> ParseGeneric(string html, string? idPattern)
=> throw new NotImplementedException();
private Task StoreHistoryAsync(string monitorId,
List<MonitorEntry> entries, AgentToolContext ctx, CancellationToken ct)
=> throw new NotImplementedException();
private Task<ToolResult> GetHistoryAsync(string monitorId,
JsonElement input, AgentToolContext ctx, CancellationToken ct)
=> throw new NotImplementedException();
private Task<ToolResult> GetStatusAsync(string monitorId,
AgentToolContext ctx, CancellationToken ct)
=> throw new NotImplementedException();
}
public sealed record MonitorEntry(
long NumericId,
string RawId,
string DetailUrl,
DateTime? PublishedAt,
DateTime? TradedAt,
Dictionary<string, string> Fields // flexible Felder je nach Parser
);
```
### Core-Erweiterung: IStateStore
`WebMonitor` braucht persistenten State zwischen Runs (die letzte bekannte Trade-ID).
Dafür muss `AgentToolContext` um ein `IStateStore` erweitert werden:
```csharp
// Core/Tools/AgentToolContext.cs — erweitern:
public sealed record AgentToolContext(
string AgentId,
string InstanceId,
IReadOnlyDictionary<string, object?> ToolConfig,
IStateStore StateStore, // NEU
ILogger Logger,
CancellationToken CancellationToken
);
// Core/State/IStateStore.cs — neues Interface:
namespace ClawdDotNet.Core.State;
public interface IStateStore
{
Task<string?> GetAsync(string key, CancellationToken ct);
Task SetAsync(string key, string value, CancellationToken ct);
Task DeleteAsync(string key, CancellationToken ct);
}
// Implementierungen (im Core oder als separates Projekt):
// - JsonFileStateStore → speichert in ./data/{instanceId}/state.json
// - SqliteStateStore → speichert in ./data/{instanceId}/state.db (empfohlen)
// Beide implementieren IStateStore.
// SqliteStateStore ist bevorzugt: atomic writes, kein Datenverlust bei Absturz.
// NuGet: Microsoft.Data.Sqlite
```
---
## Automatisches Monitoring via Scheduler
Das `WebMonitor`-Tool wird typischerweise nicht interaktiv genutzt, sondern
vom Scheduler getriggert. Konfiguration in der AgentConfig:
```json
{
"agentId": "capitol-watcher",
"displayName": "Capitol Trades Monitor",
"model": "google/gemini-flash-1.5",
"systemPrompt": "Du überwachst Politiker-Trades auf Capitol Trades. Bei neuen Trades analysierst du: Welcher Sektor? Auffälliges Timing? Cluster mehrerer Politiker beim selben Wert? Fasse neue Trades prägnant zusammen. Verwende niemals Daten ohne fetchedAt-Feld.",
"tools": {
"WebMonitor": {
"monitors": {
"capitol_trades": {
"url": "https://www.capitoltrades.com/trades?pageSize=96",
"checkIntervalMinutes": 30,
"idPattern": "/trades/(\\d+)",
"parser": "capitol_trades",
"alertOnNew": true,
"storeHistory": true
}
}
},
"Database": {
"connectionString": "...",
"allowedTables": ["capitol_trades_history", "trade_alerts"]
},
"Mail": {
"smtpHost": "...",
"allowedRecipients": ["owner@example.com"]
}
},
"scheduler": {
"cron": "*/30 * * * *",
"runOnStart": true
},
"loopGuard": {
"maxSteps": 10,
"maxTokens": 30000
}
}
```
Ablauf eines automatischen Runs:
1. Scheduler feuert alle 30 Minuten
2. Agent ruft `WebMonitor.check(capitol_trades)` auf
3. Tool liefert neue Trades als Delta
4. Agent analysiert: Cluster? Insider-Timing? Sektor-Häufung?
5. Bei relevanten Funden: `Mail.send` oder `Database.insert` zur Archivierung
6. Run beendet — kein manueller Eingriff nötig
---
## Implementierungsreihenfolge (für Claude Code)
Bearbeite diesen Abschnitt nach Abschluss der Core- und WinForms-Phase:
1. `IStateStore` Interface + `SqliteStateStore` Implementierung in Core anlegen
2. `AgentToolContext` um `IStateStore` erweitern, alle bestehenden Tool-Calls anpassen
3. `DirectApiTool` implementieren:
- Twelve Data quote + history
- Yahoo Finance quote (kein Key nötig, als Fallback)
- CoinGecko crypto
- Timestamp-Extraktion aus jeweiligem Response-Format
4. `WebFetchTool` implementieren:
- Domain-Whitelist-Check
- HttpClient mit no-cache Headers
- Einfaches HTML-Stripping (ohne externe Libs)
- RSS/Atom-Parser mit System.Xml.Linq
- Timestamp-Extraktion aus HTML-Meta-Tags
5. `WebMonitorTool` implementieren:
- `ParseCapitolTrades` als ersten Parser (Regex auf HTML-Tabelle)
- `CheckForNewEntriesAsync` mit IStateStore-Integration
- `GetHistory` und `GetStatus` Aktionen
- `ParseSecForm4` als zweiten Parser
6. xUnit-Tests:
- `ParseCapitolTrades` gegen gespeichertes HTML-Sample testen
- Delta-Logik: lastKnownId=X, neue IDs=[X-1, X, X+1, X+2] → nur X+1 und X+2
- Domain-Whitelist: erlaubte und gesperrte Domain testen
- Timestamp-Extraktion: Last-Modified Header, og:article:published_time, kein Header
7. Beispiel-Config `capitol-team.json` anlegen
8. In `Program.cs`: WebMonitorTool registrieren, SqliteStateStore als IStateStore in DI
**Beginne mit Schritt 1 dieses Abschnitts.**
+718
View File
@@ -0,0 +1,718 @@
# ClawdDotNet Prompt-Anhang: Tool "TelegramClient"
Dieser Abschnitt ergänzt die bestehenden Prompt-Anhänge und definiert das Tool
`TelegramClient`, das über die Telegram Client API (MTProto) auf den persönlichen
Telegram-Account des Nutzers zugreift. Dieses Tool ist NICHT der bereits vorhandene
Telegram Bot — es nutzt die User-API und kann damit auch Nachrichten aus privaten
Gruppen lesen, in denen der Nutzer Mitglied ist.
**Nur Lese-Zugriff. Kein Senden von Nachrichten.**
---
## Voraussetzungen
### Telegram API Credentials
Der Nutzer muss einmalig auf https://my.telegram.org/apps eine App registrieren.
Ergebnis: `api_id` (Integer) und `api_hash` (String). Diese Werte repräsentieren
die Anwendung (nicht den User) und werden in der InstanceConfig gespeichert.
### Erstmalige Authentifizierung
Beim allerersten Start muss der Nutzer sich interaktiv authentifizieren:
1. Telefonnummer eingeben
2. Verifizierungscode eingeben (kommt per Telegram-App, SMS oder Anruf)
3. Optional: 2FA-Passwort eingeben
Danach wird eine Session-Datei gespeichert. Alle weiteren Starts verwenden
diese Session automatisch — kein erneuter Login nötig.
### NuGet
```xml
<PackageReference Include="WTelegramClient" Version="4.*" />
```
---
## Architektur: Shared Client, Read-Only Access
### Warum ein Shared Client?
Die Telegram Client API erlaubt pro Telefonnummer nur EINE aktive MTProto-Verbindung.
Mehrere Agent-Runs dürfen NICHT jeweils einen eigenen WTelegram.Client instanziieren —
das würde die Session invalidieren und den Login auf dem echten Telegram-Client killen.
Lösung: Ein einziger `WTelegram.Client` wird im Host instanziiert und als Singleton
an alle Agenten weitergegeben. Das Tool selbst ist stateless und greift über den
Shared Client auf Telegram zu.
```
Host (Program.cs)
└─ TelegramClientManager (Singleton)
└─ WTelegram.Client (eine Instanz pro Prozess)
├─ Agent A: TelegramClient-Tool → liest Gruppe "Aktien-Chat"
├─ Agent B: TelegramClient-Tool → liest Gruppe "Krypto-Signals"
└─ Agent C: TelegramClient-Tool → liest DMs
```
### Concurrency
WTelegram.Client ist NICHT thread-safe für gleichzeitige API-Calls.
Der `TelegramClientManager` muss alle Aufrufe über einen `SemaphoreSlim(1,1)`
serialisieren. Da wir nur lesen und die Calls schnell sind (<500ms), ist
die Serialisierung kein Bottleneck.
---
## TelegramClientManager
**Datei: `Host/Services/TelegramClientManager.cs`**
Verwaltet die einzige WTelegram.Client-Instanz. Wird im Host als Singleton registriert.
```csharp
namespace ClawdDotNet.Host.Services;
using WTelegram;
using TL;
public sealed class TelegramClientManager : IAsyncDisposable
{
private Client? _client;
private User? _self;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ILogger<TelegramClientManager> _logger;
// Config-Werte aus InstanceConfig
private readonly int _apiId;
private readonly string _apiHash;
private readonly string _phoneNumber;
private readonly string _sessionPath;
private readonly string? _2faPassword;
// Event für interaktive Login-Aufforderung (Code-Eingabe via UI)
public event Func<string, Task<string>>? OnLoginCodeRequired;
public event Func<Task<string>>? On2FAPasswordRequired;
public bool IsConnected => _client?.User != null;
public User? Self => _self;
public TelegramClientManager(
Config.InstanceConfig config,
ILogger<TelegramClientManager> logger)
{
_logger = logger;
var tgConfig = config.TelegramClient
?? throw new InvalidOperationException("TelegramClient config missing in InstanceConfig");
_apiId = tgConfig.ApiId;
_apiHash = tgConfig.ApiHash;
_phoneNumber = tgConfig.PhoneNumber;
_sessionPath = Path.Combine(config.WorkingDirectory, $"telegram_{config.InstanceId}.session");
_2faPassword = tgConfig.Password2FA;
}
public async Task ConnectAsync(CancellationToken ct)
{
// WTelegram.Client mit Config-Callback instanziieren
_client = new Client(ConfigCallback, _sessionPath);
// Logging an ILogger umleiten
Helpers.Log = (lvl, msg) =>
_logger.Log((Microsoft.Extensions.Logging.LogLevel)lvl, "WTelegram: {Message}", msg);
_self = await _client.LoginUserIfNeeded();
_logger.LogInformation(
"Telegram: logged in as {Name} (id {Id})",
_self.first_name, _self.id);
}
private string? ConfigCallback(string what) => what switch
{
"api_id" => _apiId.ToString(),
"api_hash" => _apiHash,
"phone_number" => _phoneNumber,
"session_pathname" => _sessionPath,
// Interaktiver Code — wird über Event an die UI weitergeleitet
"verification_code" => OnLoginCodeRequired != null
? OnLoginCodeRequired("Bitte Telegram-Verifizierungscode eingeben:").Result
: throw new InvalidOperationException(
"Verification code required but no UI handler registered. " +
"Connect OnLoginCodeRequired to prompt the user."),
// 2FA-Passwort — aus Config oder interaktiv
"password" => _2faPassword
?? (On2FAPasswordRequired != null
? On2FAPasswordRequired().Result
: throw new InvalidOperationException(
"2FA password required but not configured.")),
_ => null // Defaults für alles andere
};
/// Alle Dialoge (Chats, Gruppen, Kanäle, DMs) auflisten
public async Task<Messages_Dialogs> GetAllDialogsAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try { return await _client!.Messages_GetAllDialogs(); }
finally { _gate.Release(); }
}
/// Alle Gruppen/Kanäle auflisten (ohne DMs)
public async Task<Messages_Chats> GetAllChatsAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try { return await _client!.Messages_GetAllChats(); }
finally { _gate.Release(); }
}
/// Nachrichten aus einem Chat/Kanal/Gruppe lesen
/// peer: Chat-ID oder Username
/// minId: nur Nachrichten neuer als diese ID (für Delta-Abfragen)
/// limit: max. Anzahl Nachrichten
public async Task<Messages_MessagesBase> GetMessagesAsync(
InputPeer peer, int minId = 0, int limit = 50, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
return await _client!.Messages_GetHistory(
peer, offset_id: 0, offset_date: default,
add_offset: 0, limit: limit, max_id: 0, min_id: minId, hash: 0);
}
finally { _gate.Release(); }
}
/// Peer über Username oder Chat-ID auflösen
public async Task<IPeerInfo> ResolveUsernameAsync(string username, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try { return await _client!.Contacts_ResolveUsername(username.TrimStart('@')); }
finally { _gate.Release(); }
}
/// Peer über bekannte Chat-ID auflösen (benötigt vorherigen GetAllChats/Dialogs Aufruf)
public InputPeer? GetInputPeerFromCache(long chatId)
=> _client!.GetInputPeerID(chatId);
public async ValueTask DisposeAsync()
{
_client?.Dispose();
_gate.Dispose();
}
}
```
---
## TelegramClientTool — das IAgentTool
**Datei: `ClawdDotNet.Tools.TelegramClient/TelegramClientTool.cs`**
```csharp
namespace ClawdDotNet.Tools.TelegramClient;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Host.Services; // TelegramClientManager
using TL;
using System.Text.Json;
public sealed class TelegramClientTool : IAgentTool
{
// Manager wird per DI injiziert (Singleton im Host)
private readonly TelegramClientManager _tg;
public TelegramClientTool(TelegramClientManager tg) => _tg = tg;
public string Name => "TelegramClient";
public string Description => """
Liest Nachrichten aus dem persönlichen Telegram-Account des Nutzers.
Zugriff auf alle Chats, Gruppen und Kanäle in denen der Nutzer Mitglied ist.
NUR LESEN kein Senden, kein Löschen, kein Bearbeiten.
Aktionen: list_chats, read_messages, read_new
""";
public JsonElement InputSchema => JsonDocument.Parse("""
{
"type": "object",
"required": ["action"],
"properties": {
"action": {
"type": "string",
"enum": ["list_chats", "read_messages", "read_new"],
"description": "list_chats: alle Chats/Gruppen/Kanäle auflisten. read_messages: letzte N Nachrichten aus einem Chat lesen. read_new: nur neue Nachrichten seit letztem Abruf."
},
"chatId": {
"type": "integer",
"description": "Chat-ID aus list_chats Ergebnis. Erforderlich für read_messages und read_new."
},
"username": {
"type": "string",
"description": "Alternativ zu chatId: @username einer Gruppe/Person auflösen."
},
"limit": {
"type": "integer",
"description": "Max. Anzahl Nachrichten (default: 30, max: 100)"
}
}
}
""").RootElement;
public async Task<ToolResult> ExecuteAsync(
JsonElement input, AgentToolContext ctx, CancellationToken ct)
{
// ---- Permission-Check: Welche Chats darf dieser Agent lesen? ----
var config = ctx.ToolConfig.GetValueOrDefault("TelegramClient")
as Dictionary<string, object?> ?? new();
var allowedChats = config.GetValueOrDefault("allowedChatIds")
as List<long>; // null = alle erlaubt
var allowedUsernames = config.GetValueOrDefault("allowedUsernames")
as List<string>;
if (!_tg.IsConnected)
return new ToolResult(false, "",
"Telegram-Client ist nicht verbunden. Bitte zuerst authentifizieren.");
var action = input.GetProperty("action").GetString()!;
return action switch
{
"list_chats" => await ListChatsAsync(allowedChats, ct),
"read_messages" => await ReadMessagesAsync(input, allowedChats, config, ctx, ct),
"read_new" => await ReadNewAsync(input, allowedChats, config, ctx, ct),
_ => new ToolResult(false, "", $"Unknown action: {action}")
};
}
private async Task<ToolResult> ListChatsAsync(
List<long>? allowedChats, CancellationToken ct)
{
var dialogs = await _tg.GetAllDialogsAsync(ct);
var chatList = new List<object>();
foreach (Dialog dialog in dialogs.dialogs)
{
var peer = dialogs.UserOrChat(dialog);
if (peer == null) continue;
var chatId = dialog.Peer.ID;
// Filter: nur erlaubte Chats anzeigen (wenn Whitelist definiert)
if (allowedChats != null && !allowedChats.Contains(chatId))
continue;
var info = peer switch
{
User user when user.IsActive => new
{
chatId = chatId,
type = "user",
name = $"{user.first_name} {user.last_name}".Trim(),
username = user.MainUsername,
unread = dialog.UnreadCount,
lastMsgId = dialog.TopMessage
} as object,
ChatBase chat when chat.IsActive => new
{
chatId = chatId,
type = chat is Channel ch
? (ch.IsGroup ? "supergroup" : "channel")
: "group",
name = chat.Title,
username = (chat as Channel)?.MainUsername,
unread = dialog.UnreadCount,
lastMsgId = dialog.TopMessage
} as object,
_ => null
};
if (info != null) chatList.Add(info);
}
var result = new
{
fetchedAt = DateTime.UtcNow,
dataAsOf = DateTime.UtcNow,
source = "telegram_client_api",
data = new
{
totalChats = chatList.Count,
chats = chatList
}
};
return new ToolResult(true, JsonSerializer.Serialize(result));
}
private async Task<ToolResult> ReadMessagesAsync(
JsonElement input, List<long>? allowedChats,
Dictionary<string, object?> config,
AgentToolContext ctx, CancellationToken ct)
{
var (peer, chatId, error) = await ResolvePeerAsync(input, allowedChats, ct);
if (error != null) return new ToolResult(false, "", error);
var limit = input.TryGetProperty("limit", out var l)
? Math.Clamp(l.GetInt32(), 1, 100)
: 30;
var messages = await _tg.GetMessagesAsync(peer!, minId: 0, limit: limit, ct: ct);
var msgList = FormatMessages(messages);
var result = new
{
fetchedAt = DateTime.UtcNow,
dataAsOf = DateTime.UtcNow,
source = $"telegram_chat_{chatId}",
data = new
{
chatId = chatId,
count = msgList.Count,
messages = msgList
}
};
return new ToolResult(true, JsonSerializer.Serialize(result));
}
private async Task<ToolResult> ReadNewAsync(
JsonElement input, List<long>? allowedChats,
Dictionary<string, object?> config,
AgentToolContext ctx, CancellationToken ct)
{
var (peer, chatId, error) = await ResolvePeerAsync(input, allowedChats, ct);
if (error != null) return new ToolResult(false, "", error);
// Letzte bekannte Message-ID aus StateStore laden
var stateKey = $"tgclient:{ctx.AgentId}:chat_{chatId}:lastMsgId";
var lastIdStr = await ctx.StateStore.GetAsync(stateKey, ct);
var lastId = int.TryParse(lastIdStr, out var id) ? id : 0;
var limit = input.TryGetProperty("limit", out var l)
? Math.Clamp(l.GetInt32(), 1, 100)
: 50;
// min_id = lastId → nur Nachrichten neuer als lastId
var messages = await _tg.GetMessagesAsync(peer!, minId: lastId, limit: limit, ct: ct);
var msgList = FormatMessages(messages);
// Neue Max-ID persistieren
if (msgList.Count > 0)
{
var newMaxId = msgList.Max(m => m.messageId);
await ctx.StateStore.SetAsync(stateKey, newMaxId.ToString(), ct);
}
var result = new
{
fetchedAt = DateTime.UtcNow,
dataAsOf = DateTime.UtcNow,
source = $"telegram_chat_{chatId}",
data = new
{
chatId = chatId,
sinceId = lastId,
newCount = msgList.Count,
messages = msgList
}
};
return new ToolResult(true, JsonSerializer.Serialize(result));
}
private async Task<(InputPeer? peer, long chatId, string? error)> ResolvePeerAsync(
JsonElement input, List<long>? allowedChats, CancellationToken ct)
{
long chatId = 0;
InputPeer? peer = null;
if (input.TryGetProperty("chatId", out var cid))
{
chatId = cid.GetInt64();
if (allowedChats != null && !allowedChats.Contains(chatId))
return (null, chatId,
$"Agent hat keinen Zugriff auf Chat {chatId}.");
peer = _tg.GetInputPeerFromCache(chatId);
if (peer == null)
{
// Cache befüllen durch einmaligen GetAllDialogs-Aufruf
await _tg.GetAllDialogsAsync(ct);
peer = _tg.GetInputPeerFromCache(chatId);
}
}
else if (input.TryGetProperty("username", out var uname))
{
var resolved = await _tg.ResolveUsernameAsync(uname.GetString()!, ct);
peer = resolved?.ToInputPeer();
chatId = peer?.ID ?? 0;
if (allowedChats != null && !allowedChats.Contains(chatId))
return (null, chatId,
$"Agent hat keinen Zugriff auf Chat @{uname.GetString()}.");
}
if (peer == null)
return (null, 0, "chatId oder username muss angegeben werden.");
return (peer, chatId, null);
}
private static List<FormattedMessage> FormatMessages(Messages_MessagesBase messages)
{
var result = new List<FormattedMessage>();
foreach (var msgBase in messages.Messages)
{
var from = messages.UserOrChat(msgBase.From ?? msgBase.Peer);
var fromName = from switch
{
User u => $"{u.first_name} {u.last_name}".Trim(),
ChatBase c => c.Title,
_ => "Unknown"
};
if (msgBase is Message msg)
{
result.Add(new FormattedMessage(
messageId: msg.ID,
date: msg.Date,
from: fromName,
fromId: msgBase.From?.ID ?? 0,
text: msg.message,
hasMedia: msg.media != null,
mediaType: msg.media?.GetType().Name,
replyToId: (msg.reply_to as MessageReplyHeader)?.reply_to_msg_id,
forwardFrom: msg.fwd_from != null
? msg.fwd_from.from_name ?? "forwarded"
: null,
views: msg.views
));
}
}
return result.OrderBy(m => m.messageId).ToList();
}
private sealed record FormattedMessage(
int messageId,
DateTime date,
string from,
long fromId,
string? text,
bool hasMedia,
string? mediaType,
int? replyToId,
string? forwardFrom,
int? views
);
}
```
---
## AgentConfig-Beispiel
```json
{
"agentId": "telegram-scout",
"displayName": "Telegram News-Scout",
"model": "google/gemini-flash-1.5",
"systemPrompt": "Du überwachst Telegram-Gruppen auf relevante Finanznachrichten und Trading-Signale. Fasse neue Nachrichten zusammen und bewerte ihre Relevanz. Verwende niemals Daten ohne fetchedAt-Feld.",
"tools": {
"TelegramClient": {
"allowedChatIds": [1001234567890, 1009876543210],
"allowedUsernames": ["aktien_chat", "crypto_signals_de"]
},
"Database": {
"connectionString": "...",
"allowedTables": ["telegram_messages", "signal_archive"]
}
},
"scheduler": {
"cron": "*/15 * * * *",
"runOnStart": true
},
"loopGuard": {
"maxSteps": 10,
"maxTokens": 30000
}
}
```
Ein Agent ohne `TelegramClient`-Eintrag in seiner Config bekommt das Tool
gar nicht erst in seinem LLM-Tool-Set angezeigt (normales Permission-Verhalten).
Ein Agent MIT Config aber ohne `allowedChatIds` (= null) darf alle Chats lesen.
---
## InstanceConfig-Erweiterung
```csharp
// Config/InstanceConfig.cs — neues optionales Feld:
public sealed class InstanceConfig
{
// ... bestehende Felder ...
public TelegramClientConfig? TelegramClient { get; set; }
}
public sealed class TelegramClientConfig
{
public int ApiId { get; set; } // von https://my.telegram.org/apps
public string ApiHash { get; set; } = ""; // von https://my.telegram.org/apps
public string PhoneNumber { get; set; } = ""; // z.B. "+491701234567"
public string? Password2FA { get; set; } // optional, nur bei aktivierter 2FA
}
```
```json
// In stock-team.json:
{
"instanceId": "stock-01",
"instanceName": "Aktien-Team",
"openRouterApiKey": "sk-or-...",
"workingDirectory": "./data/stock/",
"webServerPort": 8081,
"telegramClient": {
"apiId": 12345678,
"apiHash": "abcdef1234567890abcdef1234567890",
"phoneNumber": "+491701234567"
},
"agents": [ ... ]
}
```
---
## Interaktiver Login in WinForms
Der erste Login erfordert einen Verifizierungscode. Dieser wird über das
bestehende Chat-UI in `frm_main` abgefragt — nicht über die Konsole.
**In `frm_main` oder `Program.cs` beim Start:**
```csharp
var tgManager = provider.GetRequiredService<TelegramClientManager>();
// UI-Handler für Code-Eingabe registrieren
tgManager.OnLoginCodeRequired = async (prompt) =>
{
// Auf UI-Thread: InputBox oder Chat-Nachricht anzeigen
string? code = null;
mainForm.Invoke(() =>
{
code = Microsoft.VisualBasic.Interaction.InputBox(
prompt, "Telegram Verifizierung", "");
});
return code ?? "";
};
tgManager.On2FAPasswordRequired = async () =>
{
string? pw = null;
mainForm.Invoke(() =>
{
pw = Microsoft.VisualBasic.Interaction.InputBox(
"Bitte 2FA-Passwort eingeben:", "Telegram 2FA", "");
});
return pw ?? "";
};
// Verbindung herstellen (nutzt Session-Datei wenn vorhanden)
try
{
await tgManager.ConnectAsync(CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogError(ex, "Telegram: Login fehlgeschlagen");
// App startet trotzdem — TelegramClient-Tool meldet "nicht verbunden"
}
```
Nach erfolgreichem Login wird die Session-Datei
`./data/{instanceId}/telegram_{instanceId}.session` gespeichert.
Alle weiteren Starts loggen automatisch ein — kein Code mehr nötig.
---
## Sicherheitsregeln
1. **NUR LESEN** — Das Tool implementiert keine Sende-Funktionen.
Es gibt keine `send_message`-Action. Der `TelegramClientManager`
exponiert bewusst keine `SendMessageAsync`-Methode.
2. **Session-Datei ist sensibel** — Sie enthält die Auth-Keys für den
Telegram-Account. Die Datei liegt im `WorkingDirectory` und darf
NICHT vom FileRW-Tool erreichbar sein. In der AgentConfig für
FileRW darf der `rootPath` NIEMALS auf das WorkingDirectory zeigen
wenn dort die Session-Datei liegt. Empfehlung: Session-Datei in
einem Unterordner `./data/{instanceId}/sessions/` speichern, der
für kein FileRW-Tool als rootPath konfiguriert ist.
3. **Chat-Whitelist pro Agent** — Über `allowedChatIds` kann eingeschränkt
werden welche Chats ein Agent lesen darf. Ein Finanzmarkt-Agent hat
keinen Zugriff auf private DMs. Ein SEO-Agent hat keinen Zugriff auf
Trading-Gruppen.
4. **Rate Limiting** — Die Telegram Client API hat undokumentierte Rate-Limits.
Bei zu vielen Requests kommt ein `FLOOD_WAIT_X` Error. Der
`TelegramClientManager` muss `FloodException` abfangen und
`await Task.Delay(ex.X * 1000)` warten bevor er den Call wiederholt.
Empfehlung: mindestens 1 Sekunde Pause zwischen aufeinanderfolgenden
API-Calls (der SemaphoreSlim allein reicht nicht).
---
## Besonderheiten von WTelegramClient
### Terminology-Mapping
In der Telegram Client API unterscheiden sich die Begriffe von der Benutzeroberfläche:
| Telegram-App | API-Bezeichnung | C#-Typ |
|---|---|---|
| Gruppe (klein) | Chat | `Chat` |
| Gruppe (groß) | Channel mit IsGroup | `Channel` (IsGroup) |
| Kanal | Channel ohne IsGroup | `Channel` (!IsGroup) |
| Privatnachricht | User | `User` |
### access_hash-Problem
Telegram-API-Calls benötigen für die meisten Peers einen `access_hash`.
Dieser wird automatisch gecacht wenn vorher `Messages_GetAllDialogs()`
oder `Messages_GetAllChats()` aufgerufen wurde. Deshalb MUSS bei jedem
Start (nach Login) einmalig `GetAllDialogsAsync()` aufgerufen werden,
bevor `GetMessagesAsync()` funktioniert.
### Session-Datei
- Pfad konfigurierbar über `session_pathname` in der Config-Callback
- Verschlüsselt (Standard-Verschlüsselung von WTelegramClient)
- NICHT zwischen Rechnern portierbar (an Hardware gebunden)
- Bei Session-Problemen: Datei löschen → neuer Login erforderlich
---
## Implementierungsreihenfolge (für Claude Code)
1. `TelegramClientConfig` zu `InstanceConfig` hinzufügen
2. `TelegramClientManager` implementieren (Singleton, SemaphoreSlim, Rate-Limit-Schutz)
3. `TelegramClientTool` implementieren (list_chats, read_messages, read_new)
4. Host: Login-Flow in `Program.cs` / `frm_main` integrieren (InputBox für Code)
5. Sicherheits-Check: Session-Pfad darf nicht in FileRW-rootPath liegen
6. xUnit-Tests: FormatMessages-Serialisierung, Chat-Whitelist-Filter, Rate-Limit-Handling
7. Beispiel-Config ergänzen: `stock-team.json` mit TelegramClient-Eintrag
**Beginne mit Schritt 1 dieses Abschnitts.**
+792
View File
@@ -0,0 +1,792 @@
# ClawdDotNet Prompt-Anhang: WinForms & WebView2 Integration
Dieser Abschnitt ergänzt den Haupt-Entwicklungsprompt und behandelt ausschließlich
die WinForms-UI-Schicht mit WebView2. Er baut auf den bereits definierten Core-Typen
(AgentConfig, InstanceConfig, AgentEngine, IAgentTool etc.) auf.
---
## Übersicht: Zwei WebView2-Kontexte
Es gibt genau zwei WebView2-Kontexte im Host. Sie sind vollständig getrennt
und haben unterschiedliche Sicherheits-Scopes:
| Kontext | Control | Form | Zweck |
|---|---|---|---|
| `webView_chat` | `WebView2` in `frm_main` | Hauptfenster | Agentenübersicht + Auswahl + Chat mit einem Agenten |
| `webView_chat2` | `WebView2` in `frm_chat` | Einzelchat-Fenster | Chat mit genau einem Agenten, mehrfach öffenbar |
`frm_chat` ist bewusst ein eigenständiges, nicht-modales Fenster — es kann mehrfach
instanziiert werden, sodass der Nutzer mehrere Agenten-Chats nebeneinander
auf dem Bildschirm überwachen kann. Jede `frm_chat`-Instanz kennt genau einen `AgentId`.
---
## Sicherheitsarchitektur: Physische Trennung der WebRoots
### Zwei Hostnamen, zwei Quellen — niemals überlappend
```
Assembly (Embedded Resources) Disk (vom FileRW-Tool beschreibbar)
────────────────────────────── ──────────────────────────────────
Host/EmbeddedUI/ data/{instanceId}/
overview.html ← frm_main wwwroot/ ← Kestrel-Root
overview.css webView_chat index.html
overview.js styles/
chat.html ← frm_chat data/
chat.css webView_chat2 assets/
bridge.js
```
**Kernregel:** `EmbeddedUI/` existiert nur als Assembly-Resource.
Sie hat keinen Dateisystempfad, auf den ein Tool zeigen könnte.
Kein `FileRW`-Tool bekommt jemals einen `rootPath`, der auf `EmbeddedUI/` zeigt.
### WebView2 Virtual Host Mapping
```csharp
// Beide Mappings werden in InitWebViewAsync() jeder Form gesetzt:
// Intern aus Assembly-Stream (temporär extrahiert beim Start)
webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
"ui.clwd.internal",
EmbeddedUiManager.GetExtractedPath(), // einmalig beim App-Start nach temp/
CoreWebView2HostResourceAccessKind.DenyCors);
// Extern Agent-generierte Inhalte auf Disk
webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
"dash.clwd.local",
_instanceConfig.WwwRootPath,
CoreWebView2HostResourceAccessKind.Allow);
```
`ui.clwd.internal` → nur lesbar, kein Cross-Origin-Zugriff von außen
`dash.clwd.local` → lesbar für den WebView, schreibbar nur durch FileRW-Tool
### Startvalidierung (Pflicht, einmalig in Program.cs)
```csharp
// Sicherheitscheck beim App-Start Exception wenn verletzt:
var wwwAbs = Path.GetFullPath(instanceConfig.WwwRootPath);
var uiAbs = Path.GetFullPath(EmbeddedUiManager.GetExtractedPath());
if (wwwAbs.StartsWith(uiAbs) || uiAbs.StartsWith(wwwAbs))
throw new InvalidOperationException(
"SECURITY: WwwRootPath and EmbeddedUI path must never overlap.");
```
---
## EmbeddedUiManager
**Datei: `Host/UI/EmbeddedUiManager.cs`**
Aufgabe: HTML/CSS/JS-Dateien aus den Assembly Embedded Resources einmalig beim
Programmstart in einen temporären Ordner extrahieren. WebView2 kann nur auf
Dateisystempfade mappen, nicht direkt auf Streams.
```csharp
namespace ClawdDotNet.Host.UI;
public static class EmbeddedUiManager
{
private static string? _extractedPath;
// Einmalig beim App-Start aufrufen (vor Application.Run)
public static string ExtractToTemp()
{
if (_extractedPath != null) return _extractedPath;
var tempDir = Path.Combine(Path.GetTempPath(), "ClawdDotNet_UI",
Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "dev");
Directory.CreateDirectory(tempDir);
var asm = Assembly.GetExecutingAssembly();
// Alle Embedded Resources im Namespace "ClawdDotNet.Host.EmbeddedUI"
foreach (var name in asm.GetManifestResourceNames()
.Where(n => n.Contains(".EmbeddedUI.")))
{
// "ClawdDotNet.Host.EmbeddedUI.chat.css" → "chat.css"
var fileName = name.Split(".EmbeddedUI.").Last();
var dest = Path.Combine(tempDir, fileName);
using var stream = asm.GetManifestResourceStream(name)!;
using var file = File.Create(dest);
stream.CopyTo(file);
}
_extractedPath = tempDir;
return tempDir;
}
public static string GetExtractedPath()
=> _extractedPath ?? throw new InvalidOperationException(
"EmbeddedUiManager.ExtractToTemp() must be called first.");
}
```
Embedded Resources werden in der `.csproj` so eingebunden:
```xml
<ItemGroup>
<EmbeddedResource Include="EmbeddedUI\**\*" />
</ItemGroup>
```
---
## C#JavaScript Bridge
**Datei: `Host/UI/WebViewBridge.cs`**
Eine Bridge-Instanz pro WebView2-Control. Kapselt die gesamte
bidirektionale Kommunikation. Keine rohen `ExecuteScriptAsync`-Aufrufe
außerhalb dieser Klasse.
```csharp
namespace ClawdDotNet.Host.UI;
public sealed class WebViewBridge : IDisposable
{
private readonly Microsoft.Web.WebView2.WinForms.WebView2 _wv;
private readonly ILogger<WebViewBridge> _logger;
// Eingehende Nachrichten vom Browser → C#
public event Action<BridgeMessage>? MessageReceived;
public WebViewBridge(
Microsoft.Web.WebView2.WinForms.WebView2 webView,
ILogger<WebViewBridge> logger)
{
_wv = webView;
_logger = logger;
_wv.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
}
// C# → Browser: typisiert, immer als JSON
public async Task SendAsync(BridgeMessage message, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(message, BridgeJsonOptions.Default);
// Muss auf dem UI-Thread ausgeführt werden
await _wv.InvokeAsync(async () =>
await _wv.CoreWebView2.ExecuteScriptAsync(
$"window.__bridge?.receive({json})"));
}
private void OnWebMessageReceived(object? sender,
CoreWebView2WebMessageReceivedEventArgs e)
{
try
{
var msg = JsonSerializer.Deserialize<BridgeMessage>(
e.WebMessageAsJson, BridgeJsonOptions.Default);
if (msg != null) MessageReceived?.Invoke(msg);
}
catch (Exception ex)
{
_logger.LogError(ex, "Bridge: failed to deserialize incoming message");
}
}
public void Dispose()
=> _wv.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
}
```
### BridgeMessage Nachrichtenformat
**Datei: `Host/UI/BridgeMessage.cs`**
Alle Nachrichten in beide Richtungen verwenden diesen Typ.
Das `Type`-Feld bestimmt, was in `Payload` steckt.
```csharp
namespace ClawdDotNet.Host.UI;
public sealed record BridgeMessage(
string Type, // siehe Konstanten unten
string? AgentId = null,
string? Content = null, // Chat-Text, HTML-Snippet
string? Status = null, // "running" | "idle" | "error"
int? StepCount = null,
int? TokenCount = null,
string? Error = null,
object? Extra = null // type-spezifische Zusatzdaten
);
// Typ-Konstanten (C# → Browser)
public static class BridgeTypes
{
// frm_main: overview.html
public const string AgentListUpdate = "agent_list_update"; // Alle Agenten initial laden
public const string AgentStatusUpdate = "agent_status"; // Statusänderung eines Agenten
public const string SelectAgent = "select_agent"; // Agenten im Chat auswählen
// frm_main + frm_chat: chat.html
public const string ChatMessage = "chat_message"; // Neue Nachricht anzeigen
public const string ChatTyping = "chat_typing"; // Tipp-Indikator an/aus
public const string ChatHistory = "chat_history"; // Verlauf beim Öffnen laden
public const string RunStarted = "run_started"; // Agent-Run begann
public const string RunFinished = "run_finished"; // Agent-Run beendet
// Browser → C# (eingehend)
public const string UserMessage = "user_message"; // Nutzer hat Enter gedrückt
public const string OpenAgentChat = "open_agent_chat"; // "Eigenes Fenster öffnen"
public const string RunNow = "run_now"; // Manueller Run-Trigger
public const string AbortRun = "abort_run"; // Run abbrechen
}
```
---
## frm_main Hauptfenster
**Datei: `Host/Forms/frm_main.cs`**
`frm_main` enthält `webView_chat` (bereits angelegt). Dieses WebView zeigt
`overview.html`: eine Seitenleiste mit allen Agenten und einen Chat-Bereich
für den aktuell ausgewählten Agenten.
```csharp
namespace ClawdDotNet.Host.Forms;
public partial class frm_main : Form
{
private readonly InstanceConfig _instance;
private readonly AgentEngine _engine;
private readonly AgentScheduler _scheduler;
private readonly ILogger<frm_main> _logger;
private WebViewBridge? _bridge;
private string? _selectedAgentId;
// Offene Einzelchat-Fenster: AgentId → frm_chat
private readonly Dictionary<string, frm_chat> _chatWindows = new();
public frm_main(InstanceConfig instance, AgentEngine engine,
AgentScheduler scheduler, ILogger<frm_main> logger)
{
InitializeComponent();
_instance = instance;
_engine = engine;
_scheduler = scheduler;
_logger = logger;
}
private async void frm_main_Load(object sender, EventArgs e)
{
await InitWebViewAsync();
_scheduler.RunStatusChanged += OnRunStatusChanged; // Event aus Core
}
private async Task InitWebViewAsync()
{
await webView_chat.EnsureCoreWebView2Async();
// Virtual Host Mappings
webView_chat.CoreWebView2.SetVirtualHostNameToFolderMapping(
"ui.clwd.internal",
EmbeddedUiManager.GetExtractedPath(),
CoreWebView2HostResourceAccessKind.DenyCors);
webView_chat.CoreWebView2.SetVirtualHostNameToFolderMapping(
"dash.clwd.local",
_instance.WwwRootPath,
CoreWebView2HostResourceAccessKind.Allow);
_bridge = new WebViewBridge(webView_chat, /* logger */);
_bridge.MessageReceived += OnBridgeMessage;
webView_chat.CoreWebView2.Navigate(
"https://ui.clwd.internal/overview.html");
// Kurz warten bis DOM bereit, dann Agentenliste senden
await Task.Delay(300);
await PushAgentListAsync();
}
private async Task PushAgentListAsync()
{
// Alle AgentConfigs als Liste → overview.html baut die Sidebar auf
var agents = _instance.Agents.Select(a => new
{
agentId = a.AgentId,
displayName = a.DisplayName,
model = a.Model,
status = _engine.GetStatus(a.AgentId) // "idle"|"running"|"error"
});
await _bridge!.SendAsync(new BridgeMessage(
Type: BridgeTypes.AgentListUpdate,
Extra: agents));
}
private async void OnBridgeMessage(BridgeMessage msg)
{
// Immer auf UI-Thread
if (InvokeRequired) { Invoke(() => OnBridgeMessage(msg)); return; }
switch (msg.Type)
{
case BridgeTypes.UserMessage:
// Nutzer hat im Chat Enter gedrückt
if (_selectedAgentId is null || msg.Content is null) break;
await HandleUserMessageAsync(_selectedAgentId, msg.Content);
break;
case BridgeTypes.SelectAgent:
// Agenten in der Sidebar angeklickt → Chat-Verlauf laden
_selectedAgentId = msg.AgentId;
await LoadChatHistoryAsync(msg.AgentId!);
break;
case BridgeTypes.OpenAgentChat:
// "Eigenes Fenster" Button → frm_chat öffnen oder fokussieren
OpenChatWindow(msg.AgentId!);
break;
case BridgeTypes.RunNow:
_ = _engine.RunAsync(msg.AgentId!, CancellationToken.None);
break;
case BridgeTypes.AbortRun:
_engine.Abort(msg.AgentId!);
break;
}
}
private void OpenChatWindow(string agentId)
{
if (_chatWindows.TryGetValue(agentId, out var existing)
&& !existing.IsDisposed)
{
existing.BringToFront();
return;
}
var agentConfig = _instance.Agents.First(a => a.AgentId == agentId);
var frm = new frm_chat(agentConfig, _engine, /* logger */);
frm.FormClosed += (_, _) => _chatWindows.Remove(agentId);
_chatWindows[agentId] = frm;
frm.Show(this); // nicht-modal, Elternfenster = frm_main
}
private void OnRunStatusChanged(string agentId, AgentRunStatus status)
{
// Vom Scheduler/Engine gefeuert auf UI-Thread pushen
this.InvokeAsync(async () =>
await _bridge!.SendAsync(new BridgeMessage(
Type: BridgeTypes.AgentStatusUpdate,
AgentId: agentId,
Status: status.ToString().ToLower(),
StepCount: status.StepCount,
TokenCount: status.TokensUsed)));
}
private async Task HandleUserMessageAsync(string agentId, string text)
{
// Eigene Nachricht sofort anzeigen
await _bridge!.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatMessage,
AgentId: agentId,
Content: text,
Extra: new { role = "user", timestamp = DateTime.Now }));
// Tipp-Indikator an
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatTyping, AgentId: agentId));
// Chat-Run starten (non-blocking)
_ = Task.Run(async () =>
{
var result = await _engine.ChatAsync(agentId, text, CancellationToken.None);
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatMessage,
AgentId: agentId,
Content: result.FinalMessage,
Extra: new { role = "agent", timestamp = DateTime.Now }));
});
}
private async Task LoadChatHistoryAsync(string agentId)
{
var history = await _engine.GetChatHistoryAsync(agentId);
await _bridge!.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatHistory,
AgentId: agentId,
Extra: history));
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_bridge?.Dispose();
_scheduler.RunStatusChanged -= OnRunStatusChanged;
base.OnFormClosed(e);
}
}
```
---
## frm_chat Einzelchat-Fenster
**Datei: `Host/Forms/frm_chat.cs`**
`frm_chat` enthält `webView_chat2` (bereits angelegt). Dieses Fenster zeigt
den Chat mit genau einem Agenten. Es kann beliebig oft gleichzeitig geöffnet
sein — jede Instanz ist vollständig unabhängig.
```csharp
namespace ClawdDotNet.Host.Forms;
public partial class frm_chat : Form
{
private readonly AgentConfig _agentConfig;
private readonly AgentEngine _engine;
private readonly ILogger<frm_chat> _logger;
private WebViewBridge? _bridge;
public frm_chat(AgentConfig agentConfig, AgentEngine engine,
ILogger<frm_chat> logger)
{
InitializeComponent();
_agentConfig = agentConfig;
_engine = engine;
_logger = logger;
// Fenstertitel = Agent-Name
Text = $"Chat {agentConfig.DisplayName}";
}
private async void frm_chat_Load(object sender, EventArgs e)
=> await InitWebViewAsync();
private async Task InitWebViewAsync()
{
await webView_chat2.EnsureCoreWebView2Async();
// Identische Virtual Host Mappings wie frm_main
webView_chat2.CoreWebView2.SetVirtualHostNameToFolderMapping(
"ui.clwd.internal",
EmbeddedUiManager.GetExtractedPath(),
CoreWebView2HostResourceAccessKind.DenyCors);
webView_chat2.CoreWebView2.SetVirtualHostNameToFolderMapping(
"dash.clwd.local",
// WwwRootPath kommt vom InstanceConfig über DI/Singleton
ServiceLocator.Get<InstanceConfig>().WwwRootPath,
CoreWebView2HostResourceAccessKind.Allow);
_bridge = new WebViewBridge(webView_chat2, /* logger */);
_bridge.MessageReceived += OnBridgeMessage;
// chat.html lädt für einen bestimmten Agenten
webView_chat2.CoreWebView2.Navigate(
$"https://ui.clwd.internal/chat.html?agent={_agentConfig.AgentId}");
await Task.Delay(300);
// AgentInfo und Verlauf initial senden
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.AgentListUpdate,
AgentId: _agentConfig.AgentId,
Extra: new { agents = new[] { new {
agentId = _agentConfig.AgentId,
displayName = _agentConfig.DisplayName,
model = _agentConfig.Model
}}}));
await LoadChatHistoryAsync();
}
private async void OnBridgeMessage(BridgeMessage msg)
{
if (InvokeRequired) { Invoke(() => OnBridgeMessage(msg)); return; }
switch (msg.Type)
{
case BridgeTypes.UserMessage:
await HandleUserMessageAsync(msg.Content ?? "");
break;
case BridgeTypes.RunNow:
_ = _engine.RunAsync(_agentConfig.AgentId, CancellationToken.None);
break;
case BridgeTypes.AbortRun:
_engine.Abort(_agentConfig.AgentId);
break;
}
}
private async Task HandleUserMessageAsync(string text)
{
await _bridge!.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatMessage,
AgentId: _agentConfig.AgentId,
Content: text,
Extra: new { role = "user", timestamp = DateTime.Now }));
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatTyping, AgentId: _agentConfig.AgentId));
_ = Task.Run(async () =>
{
var result = await _engine.ChatAsync(
_agentConfig.AgentId, text, CancellationToken.None);
await _bridge!.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatMessage,
AgentId: _agentConfig.AgentId,
Content: result.FinalMessage,
Extra: new { role = "agent", timestamp = DateTime.Now }));
});
}
private async Task LoadChatHistoryAsync()
{
var history = await _engine.GetChatHistoryAsync(_agentConfig.AgentId);
await _bridge!.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatHistory,
AgentId: _agentConfig.AgentId,
Extra: history));
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_bridge?.Dispose();
base.OnFormClosed(e);
}
}
```
---
## Embedded HTML/JS/CSS Dateistruktur
Alle Dateien liegen in `Host/EmbeddedUI/`. Build Action: `Embedded Resource`.
### overview.html (für webView_chat in frm_main)
Dieses HTML baut die komplette Ansicht aus dem Mockup auf:
- Linke Sidebar: Agentenliste (wird via Bridge befüllt)
- Rechter Bereich: Chat mit dem aktuell ausgewählten Agenten
- "Eigenes Fenster"-Button pro Agent → sendet `open_agent_chat`-Nachricht
Kommunikationsprotokoll (JavaScript-Seite):
```javascript
// bridge.js wird von beiden HTML-Seiten eingebunden
window.__bridge = {
// Eingehend von C#
receive(msg) {
document.dispatchEvent(
new CustomEvent('bridge:' + msg.type, { detail: msg }));
},
// Ausgehend zu C#
send(msg) {
window.chrome.webview.postMessage(JSON.stringify(msg));
}
};
// Beispiel: auf Agentenliste reagieren
document.addEventListener('bridge:agent_list_update', e => {
renderSidebar(e.detail.extra.agents);
});
// Beispiel: Nachricht senden
function sendUserMessage(agentId, text) {
window.__bridge.send({
type: 'user_message',
agentId: agentId,
content: text
});
}
```
### chat.html (für webView_chat2 in frm_chat)
Vereinfachte Version ohne Sidebar — nur der Chat-Bereich.
Liest den `?agent=`-URL-Parameter beim Laden und stellt sich
damit auf den entsprechenden Agenten ein.
```javascript
// chat.html Init
const agentId = new URLSearchParams(location.search).get('agent');
document.addEventListener('bridge:chat_history', e => {
if (e.detail.agentId !== agentId) return;
renderHistory(e.detail.extra);
});
document.addEventListener('bridge:chat_message', e => {
if (e.detail.agentId !== agentId) return;
appendBubble(e.detail.extra.role, e.detail.content,
e.detail.extra.timestamp);
});
document.addEventListener('bridge:chat_typing', e => {
if (e.detail.agentId !== agentId) return;
showTypingIndicator();
});
```
---
## Program.cs Startup-Reihenfolge
```csharp
// Host/Program.cs
[STAThread]
static async Task Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
// 1. Config laden (--config Argument oder default)
var configPath = GetConfigPath(args);
var instance = InstanceConfig.LoadFromFile(configPath);
// 2. Sicherheitscheck: Pfade dürfen sich nicht überlappen
var uiPath = EmbeddedUiManager.ExtractToTemp(); // extrahiert EmbeddedUI
var wwwPath = Path.GetFullPath(instance.WwwRootPath);
if (wwwPath.StartsWith(uiPath) || uiPath.StartsWith(wwwPath))
throw new InvalidOperationException(
"SECURITY: WwwRootPath and EmbeddedUI path must never overlap.");
// 3. DI-Container aufbauen
var services = new ServiceCollection();
services.AddSingleton(instance);
services.AddSingleton<ToolRegistry>();
services.AddSingleton<PermissionGate>();
services.AddSingleton<AgentEngine>();
services.AddSingleton<AgentScheduler>();
services.AddSingleton<OpenRouterClient>();
services.AddLogging(b => b.AddConsole());
// 4. Tools registrieren (Host ist der einzige Ort, der Tool-Typen kennt)
var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<ToolRegistry>();
registry.Register(new DatabaseTool());
registry.Register(new FileRwTool());
registry.Register(new MailTool());
// 5. Scheduler starten
var scheduler = provider.GetRequiredService<AgentScheduler>();
await scheduler.StartAsync(CancellationToken.None);
// 6. WinForms starten
var mainForm = provider.GetRequiredService<frm_main>();
Application.Run(mainForm);
// 7. Cleanup
await scheduler.StopAsync(CancellationToken.None);
}
```
---
## Core-Erweiterungen für Chat-Support
Der `AgentEngine` im Core benötigt zwei zusätzliche Methoden für
den interaktiven Chat-Modus (ergänze Phase 1.7):
```csharp
// AgentEngine zusätzliche Methoden
// Interaktiver Chat: eine Nutzer-Nachricht → Agent-Antwort
// Unterschied zum autonomen Run: kein Scheduler-Trigger,
// Verlauf wird an bestehende Konversation angehängt
Task<AgentRunResult> ChatAsync(string agentId, string userMessage, CancellationToken ct);
// Chat-Verlauf aus dem StateManager laden
// Rückgabe: Liste von { role, content, timestamp }
Task<IReadOnlyList<ChatEntry>> GetChatHistoryAsync(string agentId);
// Aktuellen Run-Status abrufen (für Statusanzeige in der Sidebar)
AgentRunStatus GetStatus(string agentId);
// Laufenden Run abbrechen
void Abort(string agentId);
```
```csharp
// Neue Typen in Core/Engine/
public sealed record ChatEntry(
string Role, // "user" | "agent" | "tool"
string Content,
DateTime Timestamp
);
public sealed record AgentRunStatus(
string State, // "idle" | "running" | "error"
int StepCount,
int TokensUsed,
string? LastError
);
```
Der StateManager (Phase 1.2) speichert den Chat-Verlauf pro AgentId
persistent in der konfigurierten Datenbank oder als JSON-Datei,
sodass Verläufe auch nach Programm-Neustart verfügbar sind.
---
## Entwicklungsregeln: WinForms-spezifisch
- **Kein UI-Thread-Blocking:** Alle `await`-Aufrufe in Forms immer mit
`ConfigureAwait(false)` oder explizitem `InvokeAsync`. Bridge-Callbacks
kommen auf beliebigen Threads — immer per `InvokeRequired` prüfen.
- **WebView2 ist async:** `EnsureCoreWebView2Async()` muss abgewartet sein
bevor `CoreWebView2`-Eigenschaften gesetzt werden.
- **Keine direkte Form-zu-Form-Kommunikation:** `frm_chat` kommuniziert
ausschließlich über `AgentEngine` und `WebViewBridge`. Kein direkter
Methodenaufruf zwischen Form-Instanzen.
- **frm_chat ist nicht-modal:** Immer `frm.Show(owner)` statt
`frm.ShowDialog()`. Mehrere Instanzen mit demselben AgentId: nur
eine öffnen, fokussieren wenn vorhanden (Dictionary-Check in frm_main).
- **Bridge-Nachrichten immer typisiert:** Kein rohes JSON-String-Bauen
außerhalb von `WebViewBridge` und `BridgeMessage`.
- **EmbeddedUI ist readonly:** Keine dynamischen Schreibzugriffe auf
die extrahierten UI-Dateien zur Laufzeit. UI-Änderungen erfordern
Neu-Kompilierung.
---
## NuGet-Pakete (Host-Projekt)
```xml
<PackageReference Include="Microsoft.Web.WebView2" Version="*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="*" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="*" />
```
---
## Implementierungsreihenfolge (für Claude Code)
Bearbeite diesen Abschnitt nach Abschluss der Core-Phase (Phasen 12):
1. `EmbeddedUiManager` implementieren und Sicherheitscheck in `Program.cs` einbauen
2. `BridgeMessage` + `BridgeTypes` + `WebViewBridge` implementieren
3. Core: `ChatAsync`, `GetChatHistoryAsync`, `GetStatus`, `Abort` zu `AgentEngine` hinzufügen
4. Core: `ChatEntry` und `AgentRunStatus` Records anlegen
5. `frm_main`: `InitWebViewAsync`, `PushAgentListAsync`, Bridge-Handler implementieren
6. `frm_chat`: vollständig implementieren
7. `EmbeddedUI/bridge.js` erstellen (gemeinsame Bridge-Logik für beide HTML-Seiten)
8. `EmbeddedUI/overview.html` + `overview.css` erstellen (Sidebar + Chat-Bereich)
9. `EmbeddedUI/chat.html` + `chat.css` erstellen (Einzelchat, agentId aus URL-Parameter)
10. `Program.cs` Startup-Reihenfolge implementieren
11. xUnit-Tests: Bridge-Serialisierung, Pfad-Overlap-Check, frm_chat Isolation
**Beginne mit Schritt 1 dieses Abschnitts.**
+470
View File
@@ -0,0 +1,470 @@
# ClawdDotNet Claude Code Entwicklungs-Prompt
## Projektkontext
Wir entwickeln **ClawdDotNet** einen modularen "Coworking Space" für AI-Agenten in **C# .NET 10 / WinForms**.
Das Projekt ist bereits angelegt und hat erste UI-Steuerelemente.
Das System ermöglicht es, mehrere spezialisierte AI-Agenten parallel laufen zu lassen, die gemeinsam
strukturierte Aufgaben erledigen (z.B. Finanzmarktanalyse, Trading-Empfehlungen, SEO, Programmierung).
Als LLM-Backend wird **OpenRouter** verwendet, damit jeder Agent flexibel ein anderes Modell nutzen kann.
---
## Kernprinzipien diese gelten für JEDE Zeile Code
1. **Agenten blockieren sich niemals gegenseitig.**
Alle Agent-Runs laufen vollständig async/await mit eigenem CancellationToken.
Kein shared mutable state ohne explizites Locking. Kein Agent wartet synchron auf einen anderen.
2. **Mehrinstanzfähigkeit von Anfang an.**
Die Anwendung kann mehrfach gleichzeitig gestartet werden (z.B. ein Prozess für Aktien-Team,
ein Prozess für Krypto-Team). Jede Instanz ist vollständig isoliert:
- Eigene Konfigurationsdatei (per Instanz wählbar beim Start, z.B. `--config stock-team.json`)
- Eigene Datenbankverbindungen (keine Shared-DB-Locks ohne explizites Design dafür)
- Eigener Arbeitsordner und wwwroot-Ordner
- Eigener Netzwerk-Port für den integrierten Webserver (konfigurierbar)
- Keine globalen Singletons, keine statischen Felder mit Zustand
3. **Core ist niemals von einem Tool abhängig.**
Der Core kompiliert und läuft vollständig ohne jedes Tool. Fehlt ein Tool, bleibt der Core
funktionsfähig. Tools werden zur Laufzeit registriert.
4. **Tools sind niemals voneinander abhängig.**
Tool A darf Tool B weder referenzieren noch aufrufen. Jedes Tool ist ein eigenständiges Projekt/Assembly.
5. **Jedes Tool ist pro Agent konfiguriert.**
Agent A kann auf eine andere Datenbank zugreifen als Agent B. Agent A darf in `./wwwroot/` schreiben,
Agent B nicht. Die Konfiguration liegt in der AgentConfig, nicht im Tool-Code.
---
## Architektur-Übersicht
```
ClawdDotNet.sln
├── src/
│ ├── ClawdDotNet.Core/ ← .NET 10 Klassenbibliothek, KEIN Tool-Verweis
│ ├── ClawdDotNet.Tools.Database/ ← Tool-Plugin, nur Core-Verweis
│ ├── ClawdDotNet.Tools.FileRW/ ← Tool-Plugin, nur Core-Verweis
│ ├── ClawdDotNet.Tools.Mail/ ← Tool-Plugin, nur Core-Verweis
│ └── ClawdDotNet.Host/ ← WinForms .NET 10, verweist auf Core + alle Tools
└── configs/
├── stock-team.json
└── crypto-team.json
```
---
## Phase 1: Core implementieren
### 1.1 Kern-Interfaces und Datentypen
**Datei: `Core/Tools/IAgentTool.cs`**
```csharp
namespace ClawdDotNet.Core.Tools;
public interface IAgentTool
{
/// Eindeutiger Name, den das LLM in tool_calls verwendet
string Name { get; }
/// Natürlichsprachige Beschreibung für das LLM (geht in den System-Prompt)
string Description { get; }
/// JSON Schema des Input-Objekts (OpenAI Function Calling Format)
System.Text.Json.JsonElement InputSchema { get; }
/// Ausführung bekommt NUR seinen eigenen Kontext, nie andere Tools
Task<ToolResult> ExecuteAsync(
System.Text.Json.JsonElement input,
AgentToolContext context,
CancellationToken ct);
}
```
**Datei: `Core/Tools/AgentToolContext.cs`**
```csharp
namespace ClawdDotNet.Core.Tools;
/// Wird vom Core befüllt und an das Tool übergeben.
/// Das Tool liest seine Konfiguration NUR aus ToolConfig[tool.Name].
public sealed record AgentToolContext(
string AgentId,
string InstanceId, // Mehrinstanz-Isolation
IReadOnlyDictionary<string, object?> ToolConfig, // tool-spezifische Config aus AgentConfig
Microsoft.Extensions.Logging.ILogger Logger,
CancellationToken CancellationToken
);
```
**Datei: `Core/Tools/ToolResult.cs`**
```csharp
namespace ClawdDotNet.Core.Tools;
public sealed record ToolResult(
bool Success,
string Content, // JSON oder Plaintext, geht zurück ans LLM
string? ErrorMessage = null
);
```
### 1.2 AgentConfig (Konfigurationsmodell)
**Datei: `Core/Config/AgentConfig.cs`**
```csharp
namespace ClawdDotNet.Core.Config;
public sealed class AgentConfig
{
public string AgentId { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
public string SystemPrompt { get; set; } = "";
/// Welche Tools darf dieser Agent nutzen?
/// Key = Tool.Name, Value = tool-spezifische Konfiguration (frei definierbar je Tool)
public Dictionary<string, Dictionary<string, object?>> Tools { get; set; } = new();
public SchedulerConfig? Scheduler { get; set; }
public LoopGuardConfig LoopGuard { get; set; } = new();
}
public sealed class SchedulerConfig
{
public string Cron { get; set; } = ""; // z.B. "0 7 * * 1-5"
public bool RunOnStart { get; set; } = false;
}
public sealed class LoopGuardConfig
{
public int MaxSteps { get; set; } = 20;
public int MaxTokens { get; set; } = 80_000;
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(10);
}
```
**Datei: `Core/Config/InstanceConfig.cs`**
```csharp
namespace ClawdDotNet.Core.Config;
/// Instanz-weite Konfiguration (eine pro laufendem Prozess)
public sealed class InstanceConfig
{
public string InstanceId { get; set; } = Guid.NewGuid().ToString("N")[..8];
public string InstanceName { get; set; } = "Default";
public string OpenRouterApiKey { get; set; } = "";
public string WorkingDirectory { get; set; } = "./data/";
public int WebServerPort { get; set; } = 8080;
public List<AgentConfig> Agents { get; set; } = new();
}
```
### 1.3 Tool Registry
**Datei: `Core/Tools/ToolRegistry.cs`**
```csharp
namespace ClawdDotNet.Core.Tools;
/// Thread-safe Registry. Wird beim Start im Host befüllt.
/// Der Core kennt keine konkreten Tool-Typen.
public sealed class ToolRegistry
{
private readonly Dictionary<string, IAgentTool> _tools = new();
private readonly Lock _lock = new();
public void Register(IAgentTool tool)
{
lock (_lock)
_tools[tool.Name] = tool;
}
public IAgentTool? Get(string name)
{
lock (_lock)
return _tools.GetValueOrDefault(name);
}
/// Gibt nur die Tools zurück, für die der Agent eine Config hat
public IReadOnlyList<IAgentTool> GetForAgent(AgentConfig agent)
{
lock (_lock)
return _tools.Values
.Where(t => agent.Tools.ContainsKey(t.Name))
.ToList();
}
}
```
### 1.4 Permission Gate
**Datei: `Core/Security/PermissionGate.cs`**
```csharp
namespace ClawdDotNet.Core.Security;
public sealed class PermissionGate
{
public bool IsAllowed(string agentId, string toolName,
Config.AgentConfig agentConfig)
=> agentConfig.Tools.ContainsKey(toolName);
public void Enforce(string agentId, string toolName,
Config.AgentConfig agentConfig)
{
if (!IsAllowed(agentId, toolName, agentConfig))
throw new ToolAccessDeniedException(agentId, toolName);
}
}
public sealed class ToolAccessDeniedException(string agentId, string toolName)
: Exception($"Agent '{agentId}' has no access to tool '{toolName}'.");
```
### 1.5 Loop Guard
**Datei: `Core/Engine/LoopGuard.cs`**
```csharp
namespace ClawdDotNet.Core.Engine;
/// Pro Agent-Run instanziieren, nicht wiederverwenden.
public sealed class LoopGuard
{
private readonly Config.LoopGuardConfig _cfg;
private int _steps;
private int _tokens;
public LoopGuard(Config.LoopGuardConfig cfg) => _cfg = cfg;
public void RecordStep()
{
if (Interlocked.Increment(ref _steps) > _cfg.MaxSteps)
throw new LoopLimitExceededException($"Max steps ({_cfg.MaxSteps}) exceeded.");
}
public void RecordTokens(int count)
{
if (Interlocked.Add(ref _tokens, count) > _cfg.MaxTokens)
throw new LoopLimitExceededException($"Max tokens ({_cfg.MaxTokens}) exceeded.");
}
}
public sealed class LoopLimitExceededException(string message) : Exception(message);
```
### 1.6 OpenRouter Client
**Datei: `Core/Api/OpenRouterClient.cs`**
Implementiere einen schlanken HTTP-Client gegen `https://openrouter.ai/api/v1/chat/completions`.
Format ist OpenAI-kompatibel (JSON).
```csharp
namespace ClawdDotNet.Core.Api;
public sealed class OpenRouterClient : IDisposable
{
// Basis-URL: https://openrouter.ai/api/v1/
// Header: Authorization: Bearer {ApiKey}
// Header: HTTP-Referer: ClawdDotNet
// Format: OpenAI Chat Completions JSON
// Methoden:
// Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
// IAsyncEnumerable<ChatChunk> StreamAsync(ChatRequest request, CancellationToken ct) [optional]
// ChatRequest enthält: model, messages[], tools[] (optional), tool_choice
// ChatResponse enthält: choices[0].message (content + tool_calls), usage (prompt_tokens, completion_tokens)
}
```
Nutze `System.Net.Http.HttpClient` mit `IHttpClientFactory`-Muster.
Keine externen HTTP-Bibliotheken. Serialisierung mit `System.Text.Json`.
### 1.7 Agent Engine
**Datei: `Core/Engine/AgentEngine.cs`**
Der Kern des Agentenablaufs. Pro Agent-Run wird eine neue Instanz erzeugt.
```
Ablauf eines Agent-Runs:
1. AgentConfig laden → erlaubte Tools aus Registry holen → Tool-Beschreibungen für LLM bauen
2. System-Prompt + User-Message an OpenRouter senden (mit Tool-Definitionen)
3. Antwort prüfen:
a. Enthält tool_calls → PermissionGate.Enforce → Tool.ExecuteAsync → Ergebnis zurück ans LLM
b. Kein tool_call → Antwort ist final → Run beendet
4. Nach jedem Schritt: LoopGuard.RecordStep() + LoopGuard.RecordTokens(usage)
5. Bei Exception: sauber abbrechen, Status = Failed, Exception loggen
```
Wichtig:
- Jeder Run bekommt seinen eigenen `CancellationToken` (kombiniert aus Timeout + externer Abbruch)
- Kein `await` ohne CancellationToken
- Rückgabe: `AgentRunResult` mit Status, FinalMessage, StepCount, TokensUsed, Duration
### 1.8 Scheduler
**Datei: `Core/Scheduling/AgentScheduler.cs`**
- Nutze `System.Threading.PeriodicTimer` oder Cron-Parsing (einfaches eigenes Parsing oder NCrontab NuGet)
- Pro AgentConfig mit `Scheduler != null` wird ein eigener Timer gestartet
- Scheduled Runs werden als `Task` gestartet (fire-and-forget mit Exception-Handling)
- `RunOnStart = true` → erster Run sofort beim Registrieren
- Scheduler ist instanzweit (ein Scheduler pro Prozess, verwaltet alle Agenten)
---
## Phase 2: Tools implementieren
### Tool: Database (`ClawdDotNet.Tools.Database`)
AgentConfig-Beispiel:
```json
"Database": {
"connectionString": "Server=localhost;Database=markets;User=agent_a;Password=...;",
"allowedTables": ["quotes", "indicators", "news"]
}
```
Implementiere `IAgentTool` mit diesen Operationen (via `action`-Feld im Input):
- `query` SELECT, nur auf `allowedTables`, SQL-Injection-Schutz via Parameterized Queries
- `insert` INSERT, nur auf `allowedTables`
- `upsert` INSERT ... ON DUPLICATE KEY UPDATE
Verbindungsstring kommt IMMER aus `context.ToolConfig`, nie aus statischen Feldern.
NuGet: `MySqlConnector` (für MySQL) und/oder `MongoDB.Driver` (für MongoDB), je nach Config-Eintrag `"type": "mysql"` oder `"type": "mongodb"`.
### Tool: FileRW (`ClawdDotNet.Tools.FileRW`)
AgentConfig-Beispiel:
```json
"FileRW": {
"rootPath": "./data/analyst/",
"allowWrite": true,
"allowedExtensions": [".txt", ".json", ".html", ".md"]
}
```
Operationen: `read`, `write`, `append`, `list`, `delete`
**Sicherheit (Pflicht):**
- Alle Pfade werden mit `Path.GetFullPath` aufgelöst
- Prüfe: `resolvedPath.StartsWith(Path.GetFullPath(rootPath))` — sonst `PathTraversalException`
- Nur Dateien mit erlaubter Extension (aus `allowedExtensions`) dürfen gelesen/geschrieben werden
### Tool: Mail (`ClawdDotNet.Tools.Mail`)
AgentConfig-Beispiel:
```json
"Mail": {
"imapHost": "imap.example.com",
"imapPort": 993,
"smtpHost": "smtp.example.com",
"smtpPort": 587,
"username": "agent@example.com",
"password": "...",
"allowedRecipients": ["owner@example.com"]
}
```
Operationen: `send`, `read_inbox`, `read_message`, `mark_read`
NuGet: `MailKit`
Empfänger müssen in `allowedRecipients` stehen, sonst Exception.
---
## Phase 3: Host (WinForms)
**Datei: `Host/Program.cs`**
Startparameter: `--config <pfad>` (optional, default: `./config.json`)
```csharp
// Startup-Ablauf:
// 1. InstanceConfig aus JSON laden (Pfad aus --config Argument)
// 2. ToolRegistry befüllen (Database, FileRW, Mail registrieren)
// 3. PermissionGate, AgentScheduler, OpenRouterClient instanziieren
// 4. AgentScheduler starten (alle Agenten aus InstanceConfig)
// 5. WinForms Application.Run(new MainForm(...))
```
**MainForm:** Zeigt pro Agent eine Statuszeile (AgentId, letzter Run, Status, Token-Verbrauch).
Manueller "Run now"-Button pro Agent. Log-Output in einer ListBox oder RichTextBox.
---
## Konfigurationsbeispiel: `stock-team.json`
```json
{
"instanceId": "stock-01",
"instanceName": "Aktien-Team",
"openRouterApiKey": "sk-or-...",
"workingDirectory": "./data/stock/",
"webServerPort": 8081,
"agents": [
{
"agentId": "market-analyst",
"displayName": "Marktanalyse",
"model": "anthropic/claude-sonnet-4-5",
"systemPrompt": "Du bist ein erfahrener Marktanalyst...",
"tools": {
"Database": {
"connectionString": "Server=db1;Database=stocks;...",
"allowedTables": ["quotes", "indicators"]
},
"FileRW": {
"rootPath": "./data/stock/analyst/",
"allowWrite": true,
"allowedExtensions": [".json", ".txt"]
}
},
"scheduler": { "cron": "0 7 * * 1-5", "runOnStart": false },
"loopGuard": { "maxSteps": 25, "maxTokens": 100000 }
},
{
"agentId": "webdev",
"displayName": "Web-Entwickler",
"model": "google/gemini-flash-1.5",
"systemPrompt": "Du erstellst HTML-Dashboards...",
"tools": {
"FileRW": {
"rootPath": "./data/stock/wwwroot/",
"allowWrite": true,
"allowedExtensions": [".html", ".css", ".js", ".json"]
}
},
"loopGuard": { "maxSteps": 10, "maxTokens": 40000 }
}
]
}
```
---
## Entwicklungsregeln (für Claude Code)
- **Keine externen Frameworks** außer: `MailKit`, `MySqlConnector`, `MongoDB.Driver`, optional `NCrontab`
- **Keine statischen Zustände** in Tools oder Engine-Komponenten
- **Jeder await-Aufruf** bekommt einen CancellationToken
- **Exceptions** in Agent-Runs niemals schlucken — loggen und als `AgentRunResult` mit Status=Failed zurückgeben
- **Alle Pfadoperationen** in FileRW mit Path-Traversal-Check
- **Connection Strings** kommen immer aus `AgentToolContext.ToolConfig`, nie hardcoded
- **Tests**: Für Core-Komponenten (PermissionGate, LoopGuard, FileRW-Pfadprüfung) xUnit-Unit-Tests anlegen
- **Logging**: `Microsoft.Extensions.Logging.ILogger` überall, kein Console.WriteLine in Produktionscode
---
## Startreihenfolge für Claude Code
1. Solution-Struktur und .csproj-Dateien anlegen (Projekt-Verweise korrekt setzen)
2. Core vollständig implementieren (Interfaces, Config, Registry, Gate, Guard, Client, Engine, Scheduler)
3. Tool: FileRW (einfachstes Tool, gut testbar)
4. Tool: Database
5. Tool: Mail
6. Host: Program.cs Startup, MainForm UI
7. xUnit Tests für Core + FileRW
8. Beispiel-Configs erstellen
**Beginne mit Schritt 1.**
+256
View File
@@ -0,0 +1,256 @@
#Requires -Version 5.1
<#
.SYNOPSIS
ClawdDotNet Deployment-Paket erstellen.
Baut Release, packt alles Noetige in ein ZIP das per AnyDesk auf den Server kopiert wird.
.DESCRIPTION
Zwei Modi:
-Full Komplettes Deployment (alle Dateien, ~300 MB) — fuer Erstinstallation oder Dependency-Updates
-Quick Nur ClawdDotNet-eigene Binaries (~2 MB) — fuer normale Code-Aenderungen (DEFAULT)
.EXAMPLE
.\Deploy-Build.ps1 # Quick-Deploy (nur eigene DLLs)
.\Deploy-Build.ps1 -Full # Alles inkl. Dependencies
.\Deploy-Build.ps1 -SkipBuild # ZIP ohne vorher zu bauen (wenn Build schon aktuell)
#>
param(
[switch]$Full,
[switch]$SkipBuild,
[switch]$IncludeAgentConfigs
)
$ErrorActionPreference = 'Stop'
# ─── Pfade ───
$projectRoot = $PSScriptRoot
$buildOutput = Join-Path $projectRoot "bin\Release\net10.0-windows"
$deployDir = Join-Path $projectRoot "deploy"
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$mode = if ($Full) { "full" } else { "quick" }
$zipName = "ClawdDotNet_${mode}_${timestamp}.zip"
$zipPath = Join-Path $deployDir $zipName
# ─── 1. Build ───
if (-not $SkipBuild) {
Write-Host "`n=== Building Release ===" -ForegroundColor Cyan
Push-Location $projectRoot
dotnet build -c Release --no-restore
if ($LASTEXITCODE -ne 0) {
Write-Host "BUILD FAILED!" -ForegroundColor Red
Pop-Location
exit 1
}
Pop-Location
Write-Host "Build OK" -ForegroundColor Green
} else {
Write-Host "`n=== Build uebersprungen (SkipBuild) ===" -ForegroundColor Yellow
}
# ─── 2. Staging-Ordner vorbereiten ───
$stagingDir = Join-Path $deployDir "staging_$timestamp"
if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force }
New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null
if ($Full) {
# ── Full Deploy: Alles ausser Instances/, Logs/, PDBs ──
Write-Host "`n=== Full Deploy: Kopiere alle Dateien ===" -ForegroundColor Cyan
# Dateien im Root
Get-ChildItem $buildOutput -File | Where-Object {
$_.Extension -ne '.pdb'
} | ForEach-Object {
Copy-Item $_.FullName $stagingDir
}
# Unterordner (ohne Instances und Logs)
Get-ChildItem $buildOutput -Directory | Where-Object {
$_.Name -notin @('Instances', 'Logs')
} | ForEach-Object {
Copy-Item $_.FullName (Join-Path $stagingDir $_.Name) -Recurse
}
} else {
# ── Quick Deploy: Nur ClawdDotNet-eigene Dateien ──
Write-Host "`n=== Quick Deploy: Nur eigene Binaries ===" -ForegroundColor Cyan
Get-ChildItem $buildOutput -File | Where-Object {
$_.Name -match '^ClawdDotNet\.' -and $_.Extension -ne '.pdb'
} | ForEach-Object {
Copy-Item $_.FullName $stagingDir
}
}
# ─── 2b. Optional: Agent-Configs mitkopieren ───
if ($IncludeAgentConfigs) {
Write-Host "=== Agent-Configs werden mitgepackt ===" -ForegroundColor Yellow
$instancesSource = Join-Path $buildOutput "Instances"
if (Test-Path $instancesSource) {
$instancesDest = Join-Path $stagingDir "Instances"
# Agent-Configs + Shared-Website-Templates kopieren
Get-ChildItem $instancesSource -Recurse -File -Include "Identity.md","Soul.md","AgentSettings.json","AgentList.json","InstanceSettings.json","style.css","script.js" | ForEach-Object {
$relPath = $_.FullName.Substring($instancesSource.Length)
$destPath = Join-Path $instancesDest $relPath
$destDir = Split-Path $destPath -Parent
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null }
Copy-Item $_.FullName $destPath
}
}
}
# ─── 3. Install-Skript beilegen ───
$installScript = @'
#Requires -Version 5.1
<#
.SYNOPSIS
ClawdDotNet Update auf dem Server installieren.
Stoppt die laufende Instanz, kopiert neue Dateien, startet neu.
.EXAMPLE
.\Deploy-Install.ps1 # Standard: ClawdDotNet.exe automatisch suchen
.\Deploy-Install.ps1 -TargetDir "D:\ClawdDotNet" # Zielordner explizit angeben
#>
param(
[string]$TargetDir
)
$ErrorActionPreference = 'Stop'
# Zielordner bestimmen
if (-not $TargetDir) {
$candidates = @(
"C:\ClawdDotNet",
"D:\ClawdDotNet",
"$env:ProgramFiles\ClawdDotNet",
"$env:LOCALAPPDATA\ClawdDotNet"
)
foreach ($c in $candidates) {
if (Test-Path (Join-Path $c "ClawdDotNet.exe")) {
$TargetDir = $c
break
}
}
if (-not $TargetDir) {
Write-Host "ClawdDotNet-Installation nicht gefunden!" -ForegroundColor Red
Write-Host 'Bitte mit -TargetDir angeben, z.B.:'
Write-Host ' .\Deploy-Install.ps1 -TargetDir "D:\ClawdDotNet"'
exit 1
}
}
Write-Host ""
Write-Host "=== ClawdDotNet Update ===" -ForegroundColor Cyan
Write-Host "Ziel: $TargetDir"
# 1. Prozess stoppen
$proc = Get-Process -Name "ClawdDotNet" -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Stoppe ClawdDotNet..." -ForegroundColor Yellow
$proc | Stop-Process -Force -Confirm:$false
Start-Sleep -Seconds 2
$timeout = 15
while ((Get-Process -Name "ClawdDotNet" -ErrorAction SilentlyContinue) -and $timeout -gt 0) {
Start-Sleep -Seconds 1
$timeout--
}
if ($timeout -eq 0) {
Write-Host "WARNUNG: Prozess konnte nicht gestoppt werden!" -ForegroundColor Red
exit 1
}
Write-Host "Gestoppt." -ForegroundColor Green
} else {
Write-Host "ClawdDotNet laeuft nicht - kein Stopp noetig." -ForegroundColor Gray
}
# 2. Backup erstellen (nur eigene DLLs)
$backupDir = Join-Path $TargetDir ("_backup_" + (Get-Date -Format "yyyyMMdd_HHmmss"))
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
Get-ChildItem $TargetDir -File | Where-Object { $_.Name -match '^ClawdDotNet\.' } | ForEach-Object {
Copy-Item $_.FullName $backupDir
}
Write-Host "Backup erstellt: $backupDir" -ForegroundColor Gray
# 3. Neue Dateien kopieren
$sourceDir = $PSScriptRoot
$sourceFull = (Resolve-Path $sourceDir).Path.TrimEnd('\')
$targetFull = (Resolve-Path $TargetDir).Path.TrimEnd('\')
if ($sourceFull -eq $targetFull) {
# ZIP wurde direkt im Zielordner entpackt - Dateien sind schon da
Write-Host "ZIP wurde direkt im Zielordner entpackt - Dateien bereits vorhanden." -ForegroundColor Yellow
$fileCount = (Get-ChildItem $sourceDir -File | Where-Object { $_.Name -notin @('Deploy-Install.ps1') }).Count
} else {
$fileCount = 0
Get-ChildItem $sourceDir -File | Where-Object { $_.Name -notin @('Deploy-Install.ps1') } | ForEach-Object {
Copy-Item $_.FullName $TargetDir -Force
$fileCount++
}
# Unterordner kopieren (falls vorhanden, z.B. bei Full-Deploy)
Get-ChildItem $sourceDir -Directory | Where-Object { $_.Name -notmatch '^_backup' } | ForEach-Object {
$destSubDir = Join-Path $TargetDir $_.Name
Copy-Item $_.FullName $destSubDir -Recurse -Force
$fileCount += (Get-ChildItem $_.FullName -Recurse -File).Count
}
}
Write-Host "$fileCount Dateien aktualisiert." -ForegroundColor Green
# 4. Neu starten
Write-Host "Starte ClawdDotNet..." -ForegroundColor Cyan
$exePath = Join-Path $TargetDir "ClawdDotNet.exe"
Start-Process $exePath -WorkingDirectory $TargetDir
Start-Sleep -Seconds 3
if (Get-Process -Name "ClawdDotNet" -ErrorAction SilentlyContinue) {
Write-Host ""
Write-Host "=== Update erfolgreich! ClawdDotNet laeuft. ===" -ForegroundColor Green
} else {
Write-Host ""
Write-Host "=== WARNUNG: Prozess nicht gefunden. Bitte manuell pruefen. ===" -ForegroundColor Yellow
}
# 5. Alte Backups aufraeumen (behalte die letzten 5)
$oldBackups = Get-ChildItem $TargetDir -Directory | Where-Object { $_.Name -match '^_backup_' } | Sort-Object Name -Descending | Select-Object -Skip 5
foreach ($old in $oldBackups) {
Remove-Item $old.FullName -Recurse -Force
Write-Host "Altes Backup entfernt: $($old.Name)" -ForegroundColor Gray
}
Write-Host ""
Write-Host "Fertig." -ForegroundColor Green
'@
$installScriptPath = Join-Path $stagingDir "Deploy-Install.ps1"
# UTF-8 OHNE BOM - wichtig fuer PowerShell 5.1 auf Windows Server
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($installScriptPath, $installScript, $utf8NoBom)
# ─── 4. ZIP erstellen ───
if (-not (Test-Path $deployDir)) { New-Item -ItemType Directory -Path $deployDir -Force | Out-Null }
Write-Host "`n=== Erstelle ZIP: $zipName ===" -ForegroundColor Cyan
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -CompressionLevel Optimal
# Staging aufraeumen
Remove-Item $stagingDir -Recurse -Force
# ─── 5. Zusammenfassung ───
$zipSize = (Get-Item $zipPath).Length
Write-Host "`n========================================" -ForegroundColor Green
Write-Host " Deployment-Paket erstellt!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host " Modus: $( if ($Full) { 'FULL (alle Dateien)' } else { 'QUICK (nur eigene DLLs)' } )"
Write-Host " Datei: $zipPath"
Write-Host " Groesse: $([math]::Round($zipSize/1KB, 0)) KB ($([math]::Round($zipSize/1MB, 1)) MB)"
Write-Host ""
Write-Host " Naechste Schritte:" -ForegroundColor Yellow
Write-Host " 1. ZIP per AnyDesk auf den Server kopieren"
Write-Host " 2. Auf dem Server entpacken"
Write-Host " 3. Deploy-Install.ps1 als Admin ausfuehren"
Write-Host ""
# ZIP-Ordner im Explorer oeffnen
Start-Process "explorer.exe" "/select,`"$zipPath`""
+16
View File
@@ -0,0 +1,16 @@
<Project>
<Target Name="StampBuildDate" BeforeTargets="CoreCompile">
<PropertyGroup>
<_BuildTimestamp>$([System.DateTime]::Now.ToString("yyyy-MM-dd HH:mm:ss"))</_BuildTimestamp>
<_BuildTimestampFile>$(IntermediateOutputPath)BuildTimestamp.g.cs</_BuildTimestampFile>
</PropertyGroup>
<WriteLinesToFile
File="$(_BuildTimestampFile)"
Lines="[assembly: System.Reflection.AssemblyMetadata(&quot;BuildDate&quot;, &quot;$(_BuildTimestamp)&quot;)]"
Overwrite="true"
WriteOnlyWhenDifferent="false" />
<ItemGroup>
<Compile Include="$(_BuildTimestampFile)" />
</ItemGroup>
</Target>
</Project>
+9
View File
@@ -0,0 +1,9 @@
window.__bridge = {
receive(msg) {
document.dispatchEvent(
new CustomEvent('bridge:' + msg.type, { detail: msg }));
},
send(msg) {
window.chrome.webview.postMessage(JSON.stringify(msg));
}
};
+72
View File
@@ -0,0 +1,72 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-dark: #1e1e1e;
--bg-sidebar: #252526;
--bg-input: #2d2d2d;
--bg-bubble-user: #264f78;
--bg-bubble-agent: #333333;
--text: #cccccc;
--text-bright: #e0e0e0;
--text-dim: #888888;
--accent: #569cd6;
--border: #3e3e3e;
--hover: #2a2d2e;
}
html, body { height: 100%; font-family: 'Segoe UI', sans-serif; background: var(--bg-dark); color: var(--text); }
#app { display: flex; flex-direction: column; height: 100%; }
#chat-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-bottom: 1px solid var(--border);
background: var(--bg-sidebar);
}
#chat-agent-name { font-size: 15px; font-weight: 600; color: var(--text-bright); }
#chat-header-actions { display: flex; gap: 8px; }
#chat-header-actions button {
background: transparent; border: 1px solid var(--border); color: var(--text);
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 14px;
}
#chat-header-actions button:hover { background: var(--hover); border-color: var(--accent); }
#chat-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.chat-bubble {
max-width: 75%; padding: 10px 14px; border-radius: 10px;
font-size: 13px; line-height: 1.5; word-wrap: break-word;
white-space: pre-wrap;
}
.chat-bubble.user { background: var(--bg-bubble-user); color: var(--text-bright); align-self: flex-end; border-bottom-right-radius: 2px; }
.chat-bubble.assistant { background: var(--bg-bubble-agent); color: var(--text); align-self: flex-start; border-bottom-left-radius: 2px; }
.chat-bubble .timestamp { display: block; font-size: 10px; color: var(--text-dim); margin-top: 4px; }
.typing-indicator { align-self: flex-start; padding: 10px 14px; background: var(--bg-bubble-agent); border-radius: 10px; }
.typing-indicator span { display: inline-block; width: 6px; height: 6px; background: var(--text-dim); border-radius: 50%; margin: 0 2px; animation: typing 1.4s infinite; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-4px); } }
#chat-input-area {
display: flex; align-items: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid var(--border);
background: var(--bg-sidebar);
}
#chat-input {
flex: 1; resize: none; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg-input); color: var(--text-bright); padding: 10px 12px;
font-family: inherit; font-size: 13px; line-height: 1.4;
max-height: 120px; outline: none;
}
#chat-input:focus { border-color: var(--accent); }
#btn-send {
background: var(--accent); color: #fff; border: none; border-radius: 6px;
padding: 10px 18px; cursor: pointer; font-size: 13px; font-weight: 500;
}
#btn-send:hover { opacity: 0.85; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ClawdDotNet Chat</title>
<link rel="stylesheet" href="chat.css">
</head>
<body>
<div id="app">
<div id="chat-header">
<span id="chat-agent-name">Agent</span>
<div id="chat-header-actions">
<button id="btn-run-now" title="Jetzt ausführen">&#x25B6;</button>
<button id="btn-abort" title="Abbrechen" style="display:none;">&#x25A0;</button>
</div>
</div>
<div id="chat-messages"></div>
<div id="chat-input-area">
<textarea id="chat-input" placeholder="Nachricht eingeben..." rows="1"></textarea>
<button id="btn-send">Senden</button>
</div>
</div>
<script src="bridge.js"></script>
<script src="chat.js"></script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
(function () {
'use strict';
const agentId = new URLSearchParams(location.search).get('agent');
const chatMessages = document.getElementById('chat-messages');
const chatInput = document.getElementById('chat-input');
const btnSend = document.getElementById('btn-send');
const chatAgentName = document.getElementById('chat-agent-name');
const btnRunNow = document.getElementById('btn-run-now');
const btnAbort = document.getElementById('btn-abort');
// ─── Bridge Events ───
document.addEventListener('bridge:agent_list_update', e => {
const data = e.detail.extra;
const list = Array.isArray(data) ? data : (data?.agents ?? []);
const agent = list.find(a => a.agentId === agentId);
if (agent) chatAgentName.textContent = agent.displayName;
});
document.addEventListener('bridge:chat_history', e => {
if (e.detail.agentId !== agentId) return;
chatMessages.innerHTML = '';
const history = e.detail.extra;
if (Array.isArray(history)) {
history.forEach(entry => appendBubble(entry.role, entry.content, entry.timestamp));
}
});
document.addEventListener('bridge:chat_message', e => {
if (e.detail.agentId !== agentId) return;
removeTypingIndicator();
const extra = e.detail.extra ?? {};
appendBubble(extra.role ?? 'assistant', e.detail.content, extra.timestamp);
});
document.addEventListener('bridge:chat_typing', e => {
if (e.detail.agentId !== agentId) return;
showTypingIndicator();
});
document.addEventListener('bridge:run_started', e => {
if (e.detail.agentId !== agentId) return;
btnAbort.style.display = 'inline-block';
});
document.addEventListener('bridge:run_finished', e => {
if (e.detail.agentId !== agentId) return;
btnAbort.style.display = 'none';
removeTypingIndicator();
});
// ─── Chat Bubbles ───
function appendBubble(role, content, timestamp) {
const bubble = document.createElement('div');
bubble.className = 'chat-bubble ' + (role === 'user' ? 'user' : 'assistant');
bubble.textContent = content || '';
if (timestamp) {
const ts = document.createElement('span');
ts.className = 'timestamp';
ts.textContent = formatTime(timestamp);
bubble.appendChild(ts);
}
chatMessages.appendChild(bubble);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function showTypingIndicator() {
removeTypingIndicator();
const indicator = document.createElement('div');
indicator.className = 'typing-indicator';
indicator.id = 'typing';
indicator.innerHTML = '<span></span><span></span><span></span>';
chatMessages.appendChild(indicator);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function removeTypingIndicator() {
document.getElementById('typing')?.remove();
}
// ─── Input ───
btnSend.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
chatInput.addEventListener('input', () => {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
});
function sendMessage() {
const text = chatInput.value.trim();
if (!text || !agentId) return;
chatInput.value = '';
chatInput.style.height = 'auto';
window.__bridge.send({
type: 'user_message',
agentId: agentId,
content: text
});
}
// ─── Header Buttons ───
btnRunNow.addEventListener('click', () => {
if (!agentId) return;
window.__bridge.send({ type: 'run_now', agentId: agentId });
});
btnAbort.addEventListener('click', () => {
if (!agentId) return;
window.__bridge.send({ type: 'abort_run', agentId: agentId });
});
// ─── Helpers ───
function formatTime(ts) {
try {
const d = new Date(ts);
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch { return ''; }
}
})();
+117
View File
@@ -0,0 +1,117 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-dark: #1e1e1e;
--bg-sidebar: #252526;
--bg-chat: #1e1e1e;
--bg-input: #2d2d2d;
--bg-bubble-user: #264f78;
--bg-bubble-agent: #333333;
--text: #cccccc;
--text-bright: #e0e0e0;
--text-dim: #888888;
--accent: #569cd6;
--border: #3e3e3e;
--hover: #2a2d2e;
--agent-active: #37373d;
--status-running: #4ec9b0;
--status-idle: #4ec94e;
--status-offline: #888888;
--status-error: #f44747;
}
html, body { height: 100%; font-family: 'Segoe UI', sans-serif; background: var(--bg-dark); color: var(--text); }
#app { display: flex; height: 100%; }
/* ─── Sidebar ─── */
#sidebar { width: 280px; min-width: 220px; background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
#sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); }
#sidebar-header h2 { font-size: 14px; font-weight: 600; color: var(--text-bright); text-transform: uppercase; letter-spacing: 1px; }
#agent-list { flex: 1; overflow-y: auto; padding: 8px; }
.agent-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; margin-bottom: 4px; border-radius: 6px;
cursor: pointer; transition: background 0.15s;
}
.agent-item:hover { background: var(--hover); }
.agent-item.active { background: var(--agent-active); border-left: 3px solid var(--accent); }
.agent-status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--status-idle);
}
.agent-status-dot.running { background: var(--status-running); animation: pulse 1.5s infinite; }
.agent-status-dot.offline { background: var(--status-offline); }
.agent-status-dot.error { background: var(--status-error); }
.agent-info { flex: 1; min-width: 0; }
.agent-name { font-size: 13px; font-weight: 500; color: var(--text-bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.agent-model { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* ─── Chat Area ─── */
#chat-area { flex: 1; display: flex; flex-direction: column; background: var(--bg-chat); }
#chat-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-bottom: 1px solid var(--border);
background: var(--bg-sidebar);
}
#chat-agent-name { font-size: 15px; font-weight: 600; color: var(--text-bright); }
#chat-header-actions { display: flex; gap: 8px; }
#chat-header-actions button {
background: transparent; border: 1px solid var(--border); color: var(--text);
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 14px;
}
#chat-header-actions button:hover { background: var(--hover); border-color: var(--accent); }
#chat-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
#empty-state { display: flex; align-items: center; justify-content: center; height: 100%; }
#empty-state p { color: var(--text-dim); font-size: 14px; }
.chat-bubble {
max-width: 75%; padding: 10px 14px; border-radius: 10px;
font-size: 13px; line-height: 1.5; word-wrap: break-word;
white-space: pre-wrap;
}
.chat-bubble.user { background: var(--bg-bubble-user); color: var(--text-bright); align-self: flex-end; border-bottom-right-radius: 2px; }
.chat-bubble.assistant { background: var(--bg-bubble-agent); color: var(--text); align-self: flex-start; border-bottom-left-radius: 2px; }
.chat-bubble .timestamp { display: block; font-size: 10px; color: var(--text-dim); margin-top: 4px; }
.typing-indicator { align-self: flex-start; padding: 10px 14px; background: var(--bg-bubble-agent); border-radius: 10px; }
.typing-indicator span { display: inline-block; width: 6px; height: 6px; background: var(--text-dim); border-radius: 50%; margin: 0 2px; animation: typing 1.4s infinite; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-4px); } }
/* ─── Input ─── */
#chat-input-area {
display: flex; align-items: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid var(--border);
background: var(--bg-sidebar);
}
#chat-input {
flex: 1; resize: none; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg-input); color: var(--text-bright); padding: 10px 12px;
font-family: inherit; font-size: 13px; line-height: 1.4;
max-height: 120px; outline: none;
}
#chat-input:focus { border-color: var(--accent); }
#btn-send {
background: var(--accent); color: #fff; border: none; border-radius: 6px;
padding: 10px 18px; cursor: pointer; font-size: 13px; font-weight: 500;
white-space: nowrap;
}
#btn-send:hover { opacity: 0.85; }
#btn-send:disabled { opacity: 0.4; cursor: default; }
/* ─── Scrollbar ─── */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ClawdDotNet Agent Chat</title>
<link rel="stylesheet" href="overview.css">
</head>
<body>
<div id="app">
<aside id="sidebar">
<div id="sidebar-header">
<h2>Agenten</h2>
</div>
<div id="agent-list"></div>
</aside>
<main id="chat-area">
<div id="chat-header">
<span id="chat-agent-name">Wähle einen Agenten</span>
<div id="chat-header-actions">
<button id="btn-open-window" title="In eigenem Fenster öffnen" style="display:none;">&#x2197;</button>
<button id="btn-run-now" title="Jetzt ausführen" style="display:none;">&#x25B6;</button>
</div>
</div>
<div id="chat-messages">
<div id="empty-state">
<p>Wähle einen Agenten aus der Liste, um den Chat zu starten.</p>
</div>
</div>
<div id="chat-input-area" style="display:none;">
<textarea id="chat-input" placeholder="Nachricht eingeben..." rows="1"></textarea>
<button id="btn-send">Senden</button>
</div>
</main>
</div>
<script src="bridge.js"></script>
<script src="overview.js"></script>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
(function () {
'use strict';
let selectedAgentId = null;
let agents = [];
const agentList = document.getElementById('agent-list');
const chatMessages = document.getElementById('chat-messages');
const chatInput = document.getElementById('chat-input');
const btnSend = document.getElementById('btn-send');
const chatAgentName = document.getElementById('chat-agent-name');
const chatInputArea = document.getElementById('chat-input-area');
const emptyState = document.getElementById('empty-state');
const btnOpenWindow = document.getElementById('btn-open-window');
const btnRunNow = document.getElementById('btn-run-now');
// ─── Bridge Events ───
document.addEventListener('bridge:agent_list_update', e => {
const data = e.detail.extra;
agents = Array.isArray(data) ? data : (data?.agents ?? []);
renderSidebar();
});
document.addEventListener('bridge:agent_status', e => {
const d = e.detail;
const agent = agents.find(a => a.agentId === d.agentId);
if (agent) {
agent.status = d.status;
renderSidebar();
}
});
document.addEventListener('bridge:chat_history', e => {
if (e.detail.agentId !== selectedAgentId) return;
const history = e.detail.extra;
chatMessages.innerHTML = '';
if (Array.isArray(history)) {
history.forEach(entry => appendBubble(entry.role, entry.content, entry.timestamp));
}
});
document.addEventListener('bridge:chat_message', e => {
if (e.detail.agentId !== selectedAgentId) return;
removeTypingIndicator();
const extra = e.detail.extra ?? {};
appendBubble(extra.role ?? 'assistant', e.detail.content, extra.timestamp);
});
document.addEventListener('bridge:chat_typing', e => {
if (e.detail.agentId !== selectedAgentId) return;
showTypingIndicator();
});
document.addEventListener('bridge:run_started', e => {
const agent = agents.find(a => a.agentId === e.detail.agentId);
if (agent) { agent.status = 'running'; renderSidebar(); }
});
document.addEventListener('bridge:run_finished', e => {
const agent = agents.find(a => a.agentId === e.detail.agentId);
if (agent) { agent.status = 'idle'; renderSidebar(); }
removeTypingIndicator();
});
// ─── Sidebar ───
function renderSidebar() {
agentList.innerHTML = '';
agents.forEach(agent => {
const item = document.createElement('div');
item.className = 'agent-item' + (agent.agentId === selectedAgentId ? ' active' : '');
item.innerHTML = `
<div class="agent-status-dot ${agent.status || 'idle'}"></div>
<div class="agent-info">
<div class="agent-name">${escapeHtml(agent.displayName)}</div>
<div class="agent-model">${escapeHtml(agent.model || '')}</div>
</div>`;
item.addEventListener('click', () => selectAgent(agent.agentId));
agentList.appendChild(item);
});
}
function selectAgent(agentId) {
selectedAgentId = agentId;
const agent = agents.find(a => a.agentId === agentId);
chatAgentName.textContent = agent ? agent.displayName : agentId;
chatInputArea.style.display = 'flex';
emptyState?.remove();
btnOpenWindow.style.display = 'inline-block';
btnRunNow.style.display = 'inline-block';
chatMessages.innerHTML = '';
renderSidebar();
window.__bridge.send({
type: 'select_agent',
agentId: agentId
});
}
// ─── Chat Bubbles ───
function appendBubble(role, content, timestamp) {
const bubble = document.createElement('div');
bubble.className = 'chat-bubble ' + (role === 'user' ? 'user' : 'assistant');
bubble.textContent = content || '';
if (timestamp) {
const ts = document.createElement('span');
ts.className = 'timestamp';
ts.textContent = formatTime(timestamp);
bubble.appendChild(ts);
}
chatMessages.appendChild(bubble);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function showTypingIndicator() {
removeTypingIndicator();
const indicator = document.createElement('div');
indicator.className = 'typing-indicator';
indicator.id = 'typing';
indicator.innerHTML = '<span></span><span></span><span></span>';
chatMessages.appendChild(indicator);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function removeTypingIndicator() {
document.getElementById('typing')?.remove();
}
// ─── Input Handling ───
btnSend.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
chatInput.addEventListener('input', () => {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
});
function sendMessage() {
const text = chatInput.value.trim();
if (!text || !selectedAgentId) return;
chatInput.value = '';
chatInput.style.height = 'auto';
window.__bridge.send({
type: 'user_message',
agentId: selectedAgentId,
content: text
});
}
// ─── Header Buttons ───
btnOpenWindow.addEventListener('click', () => {
if (!selectedAgentId) return;
window.__bridge.send({ type: 'open_agent_chat', agentId: selectedAgentId });
});
btnRunNow.addEventListener('click', () => {
if (!selectedAgentId) return;
window.__bridge.send({ type: 'run_now', agentId: selectedAgentId });
});
// ─── Helpers ───
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatTime(ts) {
try {
const d = new Date(ts);
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch { return ''; }
}
})();
+8
View File
@@ -0,0 +1,8 @@
Agenten sollen den Hinweis bekommen, das sie mich informieren sollen, wenn sie der Meinung sind das ssie ein weiteres Tool benötigen, das ihnen derzeit nicht zur Verfügung steht.
SharedWorkspace für die gesamte Instanz hinzufügen ?
Ein Ort im Dateisystem, wo sie gemeinsam an verschiedenen Dateien / Projekten arbeiten können?
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

+28
View File
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Models;
/// <summary>
/// Eintrag in der AgentList.json Basisinformationen zu einem Agenten.
/// Liegt im Agents/-Ordner einer Instanz.
/// </summary>
public sealed class AgentListItem
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("folderName")]
public string FolderName { get; set; } = "";
}
/// <summary>
/// Root-Objekt der AgentList.json
/// </summary>
public sealed class AgentListFile
{
[JsonPropertyName("agents")]
public List<AgentListItem> Agents { get; set; } = new();
}
+129
View File
@@ -0,0 +1,129 @@
using System.ComponentModel;
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Models;
[TypeConverter(typeof(ExpandableObjectConverter))]
public sealed class AgentSettingsViewModel
{
private readonly AgentConfig _config;
public AgentSettingsViewModel(AgentConfig config)
{
_config = config;
}
// ──────────────── Identity ────────────────
[Category("1 - Identity")]
[DisplayName("Agent-ID")]
[Description("Eindeutige ID des Agenten. Wird intern und in Logs verwendet.")]
[ReadOnly(true)]
public string AgentId => _config.AgentId;
[Category("1 - Identity")]
[DisplayName("Anzeigename")]
[Description("Freundlicher Name des Agenten (z.B. 'Marktanalyst').")]
public string DisplayName
{
get => _config.DisplayName;
set => _config.DisplayName = value;
}
[Category("1 - Identity")]
[DisplayName("Modell")]
[Description("LLM-Modell das dieser Agent verwendet. Dropdown zeigt verfügbare Modelle von OpenRouter.")]
[TypeConverter(typeof(ModelTypeConverter))]
public string Model
{
get => _config.Model;
set => _config.Model = value;
}
[Category("1 - Identity")]
[DisplayName("Identity")]
[Description("Aus Identity.md geladen definiert WER der Agent ist. Bearbeitung über den Toolbar-Button 'Identity bearbeiten'.")]
[ReadOnly(true)]
public string IdentityStatus =>
string.IsNullOrWhiteSpace(_config.Identity) ? "(nicht definiert)" : $"✔ {_config.Identity.Split('\n').Length} Zeilen";
[Category("1 - Identity")]
[DisplayName("Soul")]
[Description("Aus Soul.md geladen definiert WIE der Agent denkt. Bearbeitung über den Toolbar-Button 'Soul bearbeiten'.")]
[ReadOnly(true)]
public string SoulStatus =>
string.IsNullOrWhiteSpace(_config.Soul) ? "(nicht definiert)" : $"✔ {_config.Soul.Split('\n').Length} Zeilen";
// ──────────────── Loop-Schutz ────────────────
[Category("2 - Loop-Schutz")]
[DisplayName("Max. Schritte pro Run")]
[Description("Maximale Anzahl LLM-Aufrufe pro Run. Verhindert Endlosschleifen bei fehlerhaften Tool-Calls.")]
public int MaxSteps
{
get => _config.LoopGuard.MaxSteps;
set => _config.LoopGuard.MaxSteps = Math.Max(1, value);
}
[Category("2 - Loop-Schutz")]
[DisplayName("Max. Tokens pro Run")]
[Description("Maximale Token-Anzahl pro einzelnem Run (Summe aller Schritte). Schützt vor unkontrollierten Kosten.")]
public int MaxTokens
{
get => _config.LoopGuard.MaxTokens;
set => _config.LoopGuard.MaxTokens = Math.Max(1000, value);
}
[Category("2 - Loop-Schutz")]
[DisplayName("Timeout (Sekunden)")]
[Description("Maximale Laufzeit pro Run in Sekunden. Danach wird der Run abgebrochen.")]
public int TimeoutSeconds
{
get => _config.LoopGuard.TimeoutSeconds;
set => _config.LoopGuard.TimeoutSeconds = Math.Max(10, value);
}
// ──────────────── Kontext-Management ────────────────
[Category("3 - Kontext-Management")]
[DisplayName("Max. Kontext-Tokens")]
[Description("Maximales Token-Budget für den gesamten Konversationskontext. Bei Überschreitung wird automatisch kompaktiert.")]
public int MaxContextTokens
{
get => _config.LoopGuard.MaxContextTokens;
set => _config.LoopGuard.MaxContextTokens = Math.Max(10_000, value);
}
[Category("3 - Kontext-Management")]
[DisplayName("Kompaktierungs-Schwelle (%)")]
[Description("Ab welchem Prozentsatz der Max. Kontext-Tokens wird kompaktiert. 80 = bei 80% Auslastung. Stufe 1: Tool-Results kürzen. Stufe 2: LLM-Zusammenfassung.")]
public int CompactionThresholdPercent
{
get => (int)(_config.LoopGuard.CompactionThreshold * 100);
set => _config.LoopGuard.CompactionThreshold = Math.Clamp(value, 50, 95) / 100.0;
}
// ──────────────── Tools (Read-Only) ────────────────
[Category("4 - Tools")]
[DisplayName("Zugewiesene Tools")]
[Description("Liste der Tool-Namen, die diesem Agent zugewiesen sind. Zuweisung über die Tabelle unten.")]
[ReadOnly(true)]
public string AssignedTools =>
_config.Tools.Count == 0
? "(keine)"
: string.Join(", ", _config.Tools.Keys);
[Category("4 - Tools")]
[DisplayName("Anzahl")]
[ReadOnly(true)]
public int ToolCount => _config.Tools.Count;
// ──────────────── Intern ────────────────
[Browsable(false)]
public AgentConfig UnderlyingConfig => _config;
public override string ToString() =>
string.IsNullOrWhiteSpace(_config.DisplayName) ? _config.AgentId : _config.DisplayName;
}
+8
View File
@@ -0,0 +1,8 @@
namespace ClawdDotNet.Models;
public sealed class AgentToolDisplayEntry
{
public bool Assigned { get; set; }
public string ToolName { get; set; } = "";
public string Description { get; set; } = "";
}
+58
View File
@@ -0,0 +1,58 @@
using System.ComponentModel;
using System.Text.Json.Serialization;
namespace ClawdDotNet.Models;
[TypeConverter(typeof(ExpandableObjectConverter))]
public sealed class AppSettings
{
[Category("Allgemein")]
[DisplayName("Log-Verzeichnis")]
[Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")]
[JsonPropertyName("logDirectory")]
public string LogDirectory { get; set; } = "./Logs";
[Category("Allgemein")]
[DisplayName("Instanzen-Verzeichnis")]
[Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
[JsonPropertyName("instancesDirectory")]
public string InstancesDirectory { get; set; } = "./Instances";
[Category("Allgemein")]
[DisplayName("Standard-Konfigurations-Datei")]
[Description("Pfad zur Standard-Instanz-Konfiguration (Legacy). Neue Instanzen nutzen das Instanzen-Verzeichnis.")]
[JsonPropertyName("defaultConfigPath")]
public string DefaultConfigPath { get; set; } = "./configs/config.json";
[Category("Allgemein")]
[DisplayName("Minimaler Log-Level")]
[Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")]
[JsonPropertyName("minimumLogLevel")]
public string MinimumLogLevel { get; set; } = "Info";
[Category("UI")]
[DisplayName("Max. Log-Zeilen in UI")]
[Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")]
[JsonPropertyName("maxLogLinesInUi")]
public int MaxLogLinesInUi { get; set; } = 2000;
[Category("UI")]
[DisplayName("Log-Aktualisierungsintervall (ms)")]
[Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")]
[JsonPropertyName("logRefreshIntervalMs")]
public int LogRefreshIntervalMs { get; set; } = 500;
[Category("API")]
[DisplayName("Status-Check-Intervall (Sek)")]
[Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
[JsonPropertyName("statusCheckIntervalSeconds")]
public int StatusCheckIntervalSeconds { get; set; } = 60;
[Category("API")]
[DisplayName("OpenRouter Base-URL")]
[Description("Basis-URL der OpenRouter-API.")]
[JsonPropertyName("openRouterBaseUrl")]
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/";
public override string ToString() => "Anwendungseinstellungen";
}
+13
View File
@@ -0,0 +1,13 @@
namespace ClawdDotNet.Models;
/// <summary>
/// Zusammenfassung einer Instanz für die Anzeige im InstanceManager.
/// </summary>
public sealed class InstanceInfo
{
public string InstanceName { get; set; } = "";
public string FolderName { get; set; } = "";
public string FolderPath { get; set; } = "";
public int AgentCount { get; set; }
public string ApiKeyStatus { get; set; } = "—";
}
+85
View File
@@ -0,0 +1,85 @@
using System.ComponentModel;
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Models;
/// <summary>
/// PropertyGrid-freundlicher Wrapper um InstanceConfig.
/// Änderungen werden direkt im zugrunde liegenden InstanceConfig-Objekt gespeichert.
/// </summary>
[TypeConverter(typeof(ExpandableObjectConverter))]
public sealed class InstanceSettingsViewModel
{
private readonly InstanceConfig _config;
public InstanceSettingsViewModel(InstanceConfig config)
{
_config = config;
}
[Category("Instanz")]
[DisplayName("Instanz-ID")]
[Description("Eindeutige ID dieser laufenden Instanz.")]
public string InstanceId
{
get => _config.InstanceId;
set => _config.InstanceId = value;
}
[Category("Instanz")]
[DisplayName("Instanzname")]
[Description("Anzeigename dieser Instanz (z.B. 'Aktien-Team').")]
public string InstanceName
{
get => _config.InstanceName;
set => _config.InstanceName = value;
}
[Category("API")]
[DisplayName("OpenRouter API-Key")]
[Description("API-Schlüssel für OpenRouter. Wird für alle Agenten dieser Instanz verwendet.")]
[PasswordPropertyText(true)]
public string OpenRouterApiKey
{
get => _config.OpenRouterApiKey;
set => _config.OpenRouterApiKey = value;
}
[Category("Verzeichnisse")]
[DisplayName("Arbeitsverzeichnis")]
[Description("Basis-Arbeitsverzeichnis für diese Instanz.")]
public string WorkingDirectory
{
get => _config.WorkingDirectory;
set => _config.WorkingDirectory = value;
}
[Category("Verzeichnisse")]
[DisplayName("Log-Verzeichnis")]
[Description("Verzeichnis für Log-Dateien dieser Instanz.")]
public string LogDirectory
{
get => _config.LogDirectory;
set => _config.LogDirectory = value;
}
[Category("Netzwerk")]
[DisplayName("Webserver-Port")]
[Description("Port für den integrierten Webserver (0 = deaktiviert).")]
public int WebServerPort
{
get => _config.WebServerPort;
set => _config.WebServerPort = value;
}
[Category("Agenten")]
[DisplayName("Anzahl Agenten")]
[Description("Anzahl der konfigurierten Agenten in dieser Instanz.")]
[ReadOnly(true)]
public int AgentCount => _config.Agents.Count;
[Browsable(false)]
public InstanceConfig UnderlyingConfig => _config;
public override string ToString() => _config.InstanceName;
}
+17
View File
@@ -0,0 +1,17 @@
namespace ClawdDotNet.Models;
public sealed class JobDisplayEntry
{
public string JobType { get; set; } = "Agent Wakeup";
public string AgentId { get; set; } = "";
public string AgentName { get; set; } = "";
public string ToolName { get; set; } = "";
public string CronExpression { get; set; } = "";
public string TaskMessage { get; set; } = "";
public string NextRun { get; set; } = "—";
public string LastRun { get; set; } = "—";
public string LastStatus { get; set; } = "—";
public bool RunOnStart { get; set; }
public string Status { get; set; } = "Aktiv";
public string JobId { get; set; } = "";
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Models;
public sealed class JobHistoryEntry
{
[JsonPropertyName("jobName")]
public string JobName { get; set; } = "";
[JsonPropertyName("agent")]
public string Agent { get; set; } = "";
[JsonPropertyName("time")]
public DateTime Time { get; set; } = DateTime.Now;
[JsonPropertyName("jobDescription")]
public string JobDescription { get; set; } = "";
[JsonPropertyName("info")]
public string Info { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "Success"; // Success, Error, Manual
}
+94
View File
@@ -0,0 +1,94 @@
using System.ComponentModel;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
namespace ClawdDotNet.Models;
/// <summary>
/// TypeConverter der im PropertyGrid eine Dropdown-Liste
/// mit verfügbaren OpenRouter-Modellen anzeigt.
/// Die Modelle werden einmalig per API abgerufen und gecached.
/// Freitext-Eingabe bleibt weiterhin möglich (CanConvertFrom = true).
/// </summary>
public sealed class ModelTypeConverter : StringConverter
{
private static List<ModelInfo>? _cachedModels;
private static bool _fetchInProgress;
private static readonly Lock _lock = new();
/// <summary>
/// Wird von außen gesetzt (beim Start der Anwendung), damit
/// der Converter Zugriff auf den OpenRouterClient hat.
/// </summary>
public static OpenRouterClient? Client { get; set; }
public override bool GetStandardValuesSupported(ITypeDescriptorContext? context) => true;
/// <summary>false = Dropdown ist editierbar (Freitext erlaubt)</summary>
public override bool GetStandardValuesExclusive(ITypeDescriptorContext? context) => false;
public override StandardValuesCollection? GetStandardValues(ITypeDescriptorContext? context)
{
EnsureModelsLoaded();
if (_cachedModels is null || _cachedModels.Count == 0)
{
// Fallback: Einige gängige Modelle
return new StandardValuesCollection(new[]
{
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4",
"openai/gpt-4.1",
"openai/gpt-4.1-mini",
"google/gemini-2.5-pro-preview",
"google/gemini-2.5-flash-preview",
"deepseek/deepseek-chat-v3-0324",
"meta-llama/llama-4-maverick"
});
}
var ids = _cachedModels.Select(m => m.Id).ToArray();
return new StandardValuesCollection(ids);
}
private static void EnsureModelsLoaded()
{
if (_cachedModels is not null || Client is null)
return;
lock (_lock)
{
if (_cachedModels is not null || _fetchInProgress)
return;
_fetchInProgress = true;
}
// Asynchronen Abruf im Hintergrund starten
_ = Task.Run(async () =>
{
try
{
var models = await Client.GetAvailableModelsAsync();
_cachedModels = models;
}
catch
{
_cachedModels = []; // Fehler → Fallback wird verwendet
}
finally
{
lock (_lock) { _fetchInProgress = false; }
}
});
}
/// <summary>
/// Kann von außen aufgerufen werden um den Cache zu leeren
/// (z.B. wenn sich der API-Key ändert).
/// </summary>
public static void InvalidateCache()
{
lock (_lock) { _cachedModels = null; }
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace ClawdDotNet.Models;
public sealed class ServiceDisplayEntry
{
public string ServiceId { get; set; } = "";
public string Name { get; set; } = "";
public string Type { get; set; } = "";
public int Port { get; set; }
public string Status { get; set; } = "Gestoppt";
public string StartedAt { get; set; } = "—";
public string Description { get; set; } = "";
public bool BuiltIn { get; set; }
}
+51
View File
@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Models;
public sealed class TokenUsageRecord
{
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.Now;
[JsonPropertyName("agentId")]
public string AgentId { get; set; } = "";
[JsonPropertyName("agentName")]
public string AgentName { get; set; } = "";
[JsonPropertyName("model")]
public string Model { get; set; } = "";
[JsonPropertyName("promptTokens")]
public int PromptTokens { get; set; }
[JsonPropertyName("completionTokens")]
public int CompletionTokens { get; set; }
[JsonPropertyName("totalTokens")]
public int TotalTokens { get; set; }
[JsonPropertyName("costUsd")]
public double CostUsd { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("stepCount")]
public int StepCount { get; set; }
[JsonPropertyName("durationMs")]
public long DurationMs { get; set; }
}
public sealed class TokenUsageFile
{
[JsonPropertyName("instanceId")]
public string InstanceId { get; set; } = "";
[JsonPropertyName("instanceName")]
public string InstanceName { get; set; } = "";
[JsonPropertyName("records")]
public List<TokenUsageRecord> Records { get; set; } = new();
}
+566
View File
@@ -0,0 +1,566 @@
using System.ComponentModel;
using System.Text.Json;
namespace ClawdDotNet.Models;
static class ConfigHelper
{
public static string GetString(Dictionary<string, object?> config, string key, string fallback = "")
{
var val = config.GetValueOrDefault(key);
return val switch
{
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? fallback,
JsonElement je => je.ToString(),
string s => s,
null => fallback,
_ => val.ToString() ?? fallback
};
}
public static int GetInt(Dictionary<string, object?> config, string key, int fallback = 0)
{
var val = config.GetValueOrDefault(key);
return val switch
{
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
JsonElement je => int.TryParse(je.ToString(), out var r) ? r : fallback,
int i => i,
_ => int.TryParse(val?.ToString(), out var r) ? r : fallback
};
}
public static bool GetBool(Dictionary<string, object?> config, string key, bool fallback = false)
{
var val = config.GetValueOrDefault(key);
return val switch
{
JsonElement je when je.ValueKind is JsonValueKind.True => true,
JsonElement je when je.ValueKind is JsonValueKind.False => false,
JsonElement je => bool.TryParse(je.ToString(), out var r) ? r : fallback,
bool b => b,
_ => bool.TryParse(val?.ToString(), out var r) ? r : fallback
};
}
public static string GetStringArray(Dictionary<string, object?> config, string key)
{
var val = config.GetValueOrDefault(key);
return val switch
{
JsonElement je when je.ValueKind == JsonValueKind.Array =>
string.Join(",", je.EnumerateArray().Select(e => e.GetString())),
object[] arr => string.Join(",", arr),
string s => s,
_ => ""
};
}
}
public enum FileRWAccessLevel
{
Denied,
Read,
ReadWrite,
Admin
}
public sealed class FileRWToolSettings
{
[Category("Persönlicher Workspace")]
[DisplayName("Erlaubte Endungen")]
[Description("Dateiendungen für den eigenen Agenten-Workspace (z.B. .txt,.json,.md)")]
public string PersonalAllowedExtensions { get; set; } = ".txt,.json,.md,.html,.js,.css";
[Category("Shared Workspace")]
[DisplayName("Zugriffslevel")]
[Description("Legt fest, welche Operationen im SharedWorkspace erlaubt sind")]
public FileRWAccessLevel SharedAccessLevel { get; set; } = FileRWAccessLevel.Denied;
[Category("Shared Workspace")]
[DisplayName("Erlaubte Endungen")]
[Description("Dateiendungen für den geteilten Workspace")]
public string SharedAllowedExtensions { get; set; } = ".txt,.json,.md";
[Category("Shared Workspace Schutz")]
[DisplayName("Geschützte Pfade")]
[Description("Komma-getrennte Pfade im SharedWorkspace die append-only sind (z.B. stocks/,archives/). Dateien dort können nur erstellt, nicht überschrieben oder gelöscht werden. Admin-Level umgeht den Schutz.")]
public string ProtectedPaths { get; set; } = "stocks/";
public Dictionary<string, object?> ToConfig() => new()
{
["personalAllowedExtensions"] = PersonalAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
["sharedAccessLevel"] = SharedAccessLevel.ToString(),
["sharedAllowedExtensions"] = SharedAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
["protectedPaths"] = ProtectedPaths.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
};
public static FileRWToolSettings FromConfig(Dictionary<string, object?> config) => new()
{
PersonalAllowedExtensions = ConfigHelper.GetStringArray(config, "personalAllowedExtensions") is { Length: > 0 } s1
? s1 : (ConfigHelper.GetStringArray(config, "allowedExtensions") is { Length: > 0 } sOld ? sOld : ".txt,.json,.md,.html,.js,.css"),
SharedAccessLevel = Enum.TryParse<FileRWAccessLevel>(ConfigHelper.GetString(config, "sharedAccessLevel"), true, out var level)
? level : FileRWAccessLevel.Denied,
SharedAllowedExtensions = ConfigHelper.GetStringArray(config, "sharedAllowedExtensions") is { Length: > 0 } s2
? s2 : ".txt,.json,.md",
ProtectedPaths = ConfigHelper.GetStringArray(config, "protectedPaths") is { Length: > 0 } s3
? s3 : "stocks/"
};
}
public sealed class MailToolSettings
{
[Category("Mail - Konto")]
[DisplayName("Benutzername")]
public string Username { get; set; } = "";
[Category("Mail - Konto")]
[DisplayName("Passwort")]
[PasswordPropertyText(true)]
public string Password { get; set; } = "";
[Category("Mail - IMAP")]
[DisplayName("IMAP-Host")]
public string ImapHost { get; set; } = "";
[Category("Mail - IMAP")]
[DisplayName("IMAP-Port")]
public int ImapPort { get; set; } = 993;
[Category("Mail - SMTP")]
[DisplayName("SMTP-Host")]
public string SmtpHost { get; set; } = "";
[Category("Mail - SMTP")]
[DisplayName("SMTP-Port")]
public int SmtpPort { get; set; } = 587;
[Category("Mail - Sicherheit")]
[DisplayName("Erlaubte Empfänger")]
[Description("Komma-getrennte Liste erlaubter E-Mail-Adressen")]
public string AllowedRecipients { get; set; } = "";
public Dictionary<string, object?> ToConfig() => new()
{
["username"] = Username,
["password"] = Password,
["imapHost"] = ImapHost,
["imapPort"] = ImapPort,
["smtpHost"] = SmtpHost,
["smtpPort"] = SmtpPort,
["allowedRecipients"] = AllowedRecipients.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
};
public static MailToolSettings FromConfig(Dictionary<string, object?> config) => new()
{
Username = ConfigHelper.GetString(config, "username"),
Password = ConfigHelper.GetString(config, "password"),
ImapHost = ConfigHelper.GetString(config, "imapHost"),
ImapPort = ConfigHelper.GetInt(config, "imapPort", 993),
SmtpHost = ConfigHelper.GetString(config, "smtpHost"),
SmtpPort = ConfigHelper.GetInt(config, "smtpPort", 587),
AllowedRecipients = ConfigHelper.GetStringArray(config, "allowedRecipients")
};
}
public enum DatabaseType
{
MySql,
Postgres,
MsSql,
MongoDb
}
public enum DatabaseAccessLevel
{
[Description("Nur Lesen (SELECT/find)")]
ReadOnly,
[Description("Lesen und Schreiben (INSERT/UPDATE/DELETE)")]
ReadWrite,
[Description("Vollzugriff (Admin/Schema-Änderungen)")]
Admin
}
public sealed class DatabaseToolSettings
{
[Category("Datenbank")]
[DisplayName("Typ")]
[Description("Der zu verwendende Datenbanktyp")]
public DatabaseType Type { get; set; } = DatabaseType.MySql;
[Category("Datenbank")]
[DisplayName("Connection-String")]
public string ConnectionString { get; set; } = "";
[Category("Datenbank")]
[DisplayName("Zugriffsebene")]
[Description("Legt fest, welche Operationen der Agent ausführen darf")]
public DatabaseAccessLevel AccessLevel { get; set; } = DatabaseAccessLevel.ReadOnly;
[Category("Datenbank - Sicherheit")]
[DisplayName("Erlaubte Tabellen")]
[Description("Komma-getrennte Liste erlaubter Tabellen/Collections")]
public string AllowedTables { get; set; } = "";
public Dictionary<string, object?> ToConfig() => new()
{
["type"] = Type.ToString().ToLowerInvariant(),
["connectionString"] = ConnectionString,
["accessLevel"] = AccessLevel.ToString(),
["allowedTables"] = AllowedTables.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
};
public static DatabaseToolSettings FromConfig(Dictionary<string, object?> config) => new()
{
Type = Enum.TryParse<DatabaseType>(config.GetValueOrDefault("type")?.ToString(), true, out var result) ? result : DatabaseType.MySql,
ConnectionString = config.GetValueOrDefault("connectionString")?.ToString() ?? "",
AccessLevel = Enum.TryParse<DatabaseAccessLevel>(config.GetValueOrDefault("accessLevel")?.ToString() ?? config.GetValueOrDefault("allowWrite")?.ToString(), true, out var level)
? level
: (config.GetValueOrDefault("allowWrite") is true or "True" or "true" ? DatabaseAccessLevel.ReadWrite : DatabaseAccessLevel.ReadOnly),
AllowedTables = config.GetValueOrDefault("allowedTables") is object[] arr
? string.Join(",", arr)
: config.GetValueOrDefault("allowedTables")?.ToString() ?? ""
};
}
public sealed class FTPToolSettings
{
[Category("FTP Server")]
[DisplayName("Host")]
public string Host { get; set; } = "";
[Category("FTP Server")]
[DisplayName("Port")]
public int Port { get; set; } = 21;
[Category("FTP Server")]
[DisplayName("Benutzername")]
public string Username { get; set; } = "";
[Category("FTP Server")]
[DisplayName("Passwort")]
[PasswordPropertyText(true)]
public string Password { get; set; } = "";
[Category("FTP Lokal")]
[DisplayName("Root-Pfad")]
[Description("Basisverzeichnis für Dateiübertragungen")]
public string RootPath { get; set; } = "./data/";
public Dictionary<string, object?> ToConfig() => new()
{
["host"] = Host,
["port"] = Port,
["username"] = Username,
["password"] = Password,
["rootPath"] = RootPath
};
public static FTPToolSettings FromConfig(Dictionary<string, object?> config) => new()
{
Host = ConfigHelper.GetString(config, "host"),
Port = ConfigHelper.GetInt(config, "port", 21),
Username = ConfigHelper.GetString(config, "username"),
Password = ConfigHelper.GetString(config, "password"),
RootPath = ConfigHelper.GetString(config, "rootPath", "./data/")
};
}
public sealed class TelegramToolSettings
{
[Category("Telegram")]
[DisplayName("Bot-Token")]
[PasswordPropertyText(true)]
public string BotToken { get; set; } = "";
[Category("Telegram")]
[DisplayName("Standard Chat-ID")]
[Description("Die Standard-ID, an die Nachrichten gesendet werden, wenn keine andere ID angegeben ist.")]
public string DefaultChatId { get; set; } = "";
[Category("Telegram - Sicherheit")]
[DisplayName("Erlaubte Chat-IDs")]
[Description("Komma-getrennte Liste erlaubter Chat-IDs")]
public string AllowedChatIds { get; set; } = "";
public Dictionary<string, object?> ToConfig() => new()
{
["botToken"] = BotToken,
["defaultChatId"] = DefaultChatId,
["allowedChatIds"] = AllowedChatIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
};
public static TelegramToolSettings FromConfig(Dictionary<string, object?> config) => new()
{
BotToken = ConfigHelper.GetString(config, "botToken"),
DefaultChatId = ConfigHelper.GetString(config, "defaultChatId"),
AllowedChatIds = ConfigHelper.GetStringArray(config, "allowedChatIds")
};
}
public sealed class DirectAPIToolSettings
{
[Category("DirectAPI")]
[DisplayName("Standard-Provider")]
public string DefaultProvider { get; set; } = "twelvedata";
[Category("DirectAPI")]
[DisplayName("Cache TTL (Sekunden)")]
public int CacheTtlSeconds { get; set; } = 60;
[Category("DirectAPI - API Keys")]
[DisplayName("Twelve Data Key")]
[PasswordPropertyText(true)]
public string TwelveDataKey { get; set; } = "";
[Category("DirectAPI - API Keys")]
[DisplayName("Alpha Vantage Key")]
[PasswordPropertyText(true)]
public string AlphaVantageKey { get; set; } = "";
public Dictionary<string, object?> ToConfig() => new()
{
["defaultProvider"] = DefaultProvider,
["cacheTtlSeconds"] = CacheTtlSeconds,
["providers"] = new Dictionary<string, object?>
{
["twelvedata"] = new { apiKey = TwelveDataKey },
["alphavantage"] = new { apiKey = AlphaVantageKey }
}
};
public static DirectAPIToolSettings FromConfig(Dictionary<string, object?> config)
{
var settings = new DirectAPIToolSettings
{
DefaultProvider = ConfigHelper.GetString(config, "defaultProvider", "twelvedata"),
CacheTtlSeconds = ConfigHelper.GetInt(config, "cacheTtlSeconds", 60)
};
if (config.GetValueOrDefault("providers") is JsonElement providersJe)
{
var providers = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(providersJe.GetRawText());
if (providers != null)
{
if (providers.TryGetValue("twelvedata", out var td) && td.TryGetProperty("apiKey", out var tdk))
settings.TwelveDataKey = tdk.GetString() ?? "";
if (providers.TryGetValue("alphavantage", out var av) && av.TryGetProperty("apiKey", out var avk))
settings.AlphaVantageKey = avk.GetString() ?? "";
}
}
return settings;
}
}
public sealed class WebFetchToolSettings
{
[Category("WebFetch")]
[DisplayName("Erlaubte Domains")]
[Description("Komma-getrennte Liste (z.B. reuters.com,bloomberg.com)")]
public string AllowedDomains { get; set; } = "";
[Category("WebFetch")]
[DisplayName("Max Response KB")]
public int MaxResponseKb { get; set; } = 512;
[Category("WebFetch")]
[DisplayName("User Agent")]
public string UserAgent { get; set; } = "ClawdDotNet-Agent/1.0";
public Dictionary<string, object?> ToConfig() => new()
{
["allowedDomains"] = AllowedDomains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
["maxResponseKb"] = MaxResponseKb,
["userAgent"] = UserAgent
};
public static WebFetchToolSettings FromConfig(Dictionary<string, object?> config) => new()
{
AllowedDomains = ConfigHelper.GetStringArray(config, "allowedDomains"),
MaxResponseKb = ConfigHelper.GetInt(config, "maxResponseKb", 512),
UserAgent = ConfigHelper.GetString(config, "userAgent", "ClawdDotNet-Agent/1.0")
};
}
public sealed class WebMonitorToolSettings
{
[Category("WebMonitor")]
[DisplayName("Monitore (JSON)")]
[Description("JSON-Konfiguration der Monitore")]
public string MonitorsJson { get; set; } = "{}";
public Dictionary<string, object?> ToConfig()
{
try
{
return new Dictionary<string, object?>
{
["monitors"] = JsonSerializer.Deserialize<Dictionary<string, object?>>(MonitorsJson) ?? new()
};
}
catch { return new Dictionary<string, object?> { ["monitors"] = new Dictionary<string, object?>() }; }
}
public static WebMonitorToolSettings FromConfig(Dictionary<string, object?> config) => new()
{
MonitorsJson = config.GetValueOrDefault("monitors") is JsonElement je ? je.GetRawText() : "{}"
};
}
public sealed class AgentCommToolSettings
{
[Category("AgentComm")]
[DisplayName("Info")]
[Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, mit anderen Agenten in der gleichen Instanz zu kommunizieren.")]
[ReadOnly(true)]
public string Status { get; set; } = "Aktiv";
public Dictionary<string, object?> ToConfig() => new();
public static AgentCommToolSettings FromConfig(Dictionary<string, object?> config) => new();
}
public sealed class SocialMediaManagerToolSettings
{
// ─── X (Twitter) ───
[Category("1. X (Twitter) - API")]
[DisplayName("Bearer Token")]
[Description("X API v2 Bearer Token für die Authentifizierung")]
[PasswordPropertyText(true)]
public string XApiKey { get; set; } = "";
[Category("1. X (Twitter) - Monitoring")]
[DisplayName("Überwachte Accounts")]
[Description("Komma-getrennte Liste von X-Accounts die überwacht werden sollen (ohne @). Beispiel: elonmusk,unusual_whales,DeItaone")]
public string XWatchAccounts { get; set; } = "";
// ─── Reddit ───
[Category("2. Reddit - Monitoring")]
[DisplayName("Überwachte Subreddits")]
[Description("Komma-getrennte Liste von Subreddits die überwacht werden sollen (ohne r/). Beispiel: wallstreetbets,stocks,options")]
public string RedditWatchSubreddits { get; set; } = "";
[Category("2. Reddit - Monitoring")]
[DisplayName("Posts pro Subreddit")]
[Description("Maximale Anzahl Posts die pro Subreddit bei jedem Check abgerufen werden (Standard: 15)")]
public int RedditPostLimit { get; set; } = 15;
// ─── YouTube / STT ───
[Category("3. YouTube / STT")]
[DisplayName("OpenRouter API Key")]
[Description("API Key für Speech-to-Text Transkription über OpenRouter")]
[PasswordPropertyText(true)]
public string OpenRouterApiKey { get; set; } = "";
[Category("3. YouTube / STT")]
[DisplayName("STT Modell")]
[Description("OpenRouter Modell-ID für die Transkription")]
public string STTModel { get; set; } = "openai/whisper-1";
[Category("3. YouTube / STT")]
[DisplayName("YouTube Kanäle")]
[Description("Komma-getrennte Liste von YouTube Kanal-URLs für automatische Überwachung")]
public string YoutubeChannels { get; set; } = "";
public Dictionary<string, object?> ToConfig() => new()
{
["xApiKey"] = XApiKey,
["xWatchAccounts"] = SplitToArray(XWatchAccounts),
["redditWatchSubreddits"] = SplitToArray(RedditWatchSubreddits),
["redditPostLimit"] = RedditPostLimit,
["openRouterApiKey"] = OpenRouterApiKey,
["sttModel"] = STTModel,
["youtubeChannels"] = SplitToArray(YoutubeChannels)
};
public static SocialMediaManagerToolSettings FromConfig(Dictionary<string, object?> config) => new()
{
XApiKey = ConfigHelper.GetString(config, "xApiKey"),
XWatchAccounts = ConfigHelper.GetStringArray(config, "xWatchAccounts"),
RedditWatchSubreddits = ConfigHelper.GetStringArray(config, "redditWatchSubreddits"),
RedditPostLimit = ConfigHelper.GetInt(config, "redditPostLimit", 15),
OpenRouterApiKey = ConfigHelper.GetString(config, "openRouterApiKey"),
STTModel = ConfigHelper.GetString(config, "sttModel", "openai/whisper-1"),
YoutubeChannels = ConfigHelper.GetStringArray(config, "youtubeChannels")
};
private static string[] SplitToArray(string csv)
=> csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
public sealed class AgentEditorToolSettings
{
[Category("AgentEditor")]
[DisplayName("Info")]
[Description("Erlaubt dem Agenten, Identity und Soul anderer Agenten zu lesen, zu bearbeiten und neue Agenten zu erstellen. Keine weitere Konfiguration nötig.")]
[ReadOnly(true)]
public string Status { get; set; } = "Aktiv";
public Dictionary<string, object?> ToConfig() => new();
public static AgentEditorToolSettings FromConfig(Dictionary<string, object?> config) => new();
}
public sealed class AgentSpawnToolSettings
{
[Category("AgentSpawn")]
[DisplayName("Info")]
[Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, andere Agenten zu starten und ihnen Aufgaben zuzuweisen.")]
[ReadOnly(true)]
public string Status { get; set; } = "Aktiv";
public Dictionary<string, object?> ToConfig() => new();
public static AgentSpawnToolSettings FromConfig(Dictionary<string, object?> config) => new();
}
public static class ToolSettingsFactory
{
public static object? CreateViewModel(string toolName, Dictionary<string, object?>? config)
{
config ??= new();
return toolName switch
{
"FileRW" => FileRWToolSettings.FromConfig(config),
"Mail" => MailToolSettings.FromConfig(config),
"Database" => DatabaseToolSettings.FromConfig(config),
"Telegram" => TelegramToolSettings.FromConfig(config),
"FTP" => FTPToolSettings.FromConfig(config),
"DirectAPI" => DirectAPIToolSettings.FromConfig(config),
"WebFetch" => WebFetchToolSettings.FromConfig(config),
"WebMonitor" => WebMonitorToolSettings.FromConfig(config),
"AgentComm" => AgentCommToolSettings.FromConfig(config),
"SocialMediaManager" => SocialMediaManagerToolSettings.FromConfig(config),
"AgentSpawn" => AgentSpawnToolSettings.FromConfig(config),
"AgentEditor" => AgentEditorToolSettings.FromConfig(config),
_ => null
};
}
public static Dictionary<string, object?>? ToConfig(string toolName, object? viewModel)
{
return viewModel switch
{
FileRWToolSettings f => f.ToConfig(),
MailToolSettings m => m.ToConfig(),
DatabaseToolSettings d => d.ToConfig(),
TelegramToolSettings t => t.ToConfig(),
FTPToolSettings ftp => ftp.ToConfig(),
DirectAPIToolSettings dapi => dapi.ToConfig(),
WebFetchToolSettings wf => wf.ToConfig(),
WebMonitorToolSettings wm => wm.ToConfig(),
AgentCommToolSettings ac => ac.ToConfig(),
SocialMediaManagerToolSettings smm => smm.ToConfig(),
AgentSpawnToolSettings asp => asp.ToConfig(),
AgentEditorToolSettings ae => ae.ToConfig(),
_ => null
};
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="Microsoft.*" />
<package pattern="System.*" />
<package pattern="MySqlConnector" />
<package pattern="MongoDB.*" />
<package pattern="MailKit" />
<package pattern="MimeKit" />
<package pattern="Portable.BouncyCastle" />
<package pattern="BouncyCastle.*" />
<package pattern="Newtonsoft.Json" />
<package pattern="DnsClient" />
<package pattern="AWSSDK.*" />
<package pattern="SharpCompress" />
<package pattern="Snappier" />
<package pattern="ZstdSharp.*" />
<package pattern="Telegram.Bot.*" />
<package pattern="Telegram.Bot" />
<package pattern="Npgsql.*" />
<package pattern="Npgsql" />
<package pattern="FluentFTP" />
<package pattern="SQLitePCLRaw.*" />
<package pattern="WTelegramClient" />
</packageSource>
</packageSourceMapping>
</configuration>
+247
View File
@@ -0,0 +1,247 @@
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Logging;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Scheduling;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Core.State;
using ClawdDotNet.Services;
using ClawdDotNet.Tools.FileRW;
using ClawdDotNet.Tools.Telegram;
using ClawdDotNet.Tools.Mail;
using ClawdDotNet.Tools.Database;
using ClawdDotNet.Tools.FTP;
using ClawdDotNet.Tools.DirectAPI;
using ClawdDotNet.Tools.WebFetch;
using ClawdDotNet.Tools.WebMonitor;
using ClawdDotNet.Tools.AgentComm;
using ClawdDotNet.Tools.AgentSpawn;
using ClawdDotNet.Tools.SocialMediaManager;
using ClawdDotNet.Tools.AgentEditor;
using ClawdDotNet.Tools.TelegramClient;
using ClawdDotNet.UI;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet;
internal static class Program
{
[STAThread]
static void Main(string[] args)
{
ApplicationConfiguration.Initialize();
// ─── 0. Embedded UI extrahieren ───
EmbeddedUiManager.ExtractToTemp();
// ─── 1. App-Settings laden ───
var settingsManager = new SettingsManager();
settingsManager.Load();
var appSettings = settingsManager.AppSettings;
// ─── 2. InstanceDirectoryManager erstellen ───
var instancesDir = Path.GetFullPath(appSettings.InstancesDirectory);
var dirManager = new InstanceDirectoryManager(instancesDir);
// ─── 3. Instanz-Verzeichnis auswählen ───
string instancePath;
#if DEBUG
// Im Debug-Modus: Dev-Instanz automatisch erstellen/starten
const string devInstanceName = "Dev";
instancePath = dirManager.GetInstancePath(devInstanceName);
if (!Directory.Exists(instancePath))
{
dirManager.CreateInstance(devInstanceName);
}
#else
// Im Release-Modus: InstanceManager anzeigen
var instanceManager = new frm_InstanceManager(dirManager, settingsManager);
var dialogResult = instanceManager.ShowDialog();
if (dialogResult != DialogResult.OK || string.IsNullOrWhiteSpace(instanceManager.SelectedInstancePath))
{
return; // Benutzer hat abgebrochen
}
instancePath = instanceManager.SelectedInstancePath;
instanceManager.Dispose();
#endif
// ─── 4. Instanz-Config aus Verzeichnisstruktur laden ───
InstanceConfig instanceConfig;
try
{
instanceConfig = dirManager.LoadInstanceConfig(instancePath);
}
catch (Exception ex)
{
MessageBox.Show(
$"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}",
"ClawdDotNet Fehler",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
// ─── 5. Logging-System initialisieren ───
var logDir = Path.GetFullPath(
!string.IsNullOrWhiteSpace(instanceConfig.LogDirectory)
? instanceConfig.LogDirectory
: appSettings.LogDirectory);
var minLevel = Enum.TryParse<ClawdDotNet.Core.Logging.LogLevel>(
appSettings.MinimumLogLevel, true, out var ml)
? ml
: ClawdDotNet.Core.Logging.LogLevel.Info;
var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDir, minLevel);
var coreLogger = loggerFactory.CreateLogger("ClawdDotNet.Core.Startup");
coreLogger.LogInformation("ClawdDotNet startet Instanz: {Instance} ({Id})",
instanceConfig.InstanceName, instanceConfig.InstanceId);
coreLogger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
// ─── 6. Core-Komponenten erzeugen ───
var toolRegistry = new ToolRegistry();
toolRegistry.Register(new FileRWTool());
toolRegistry.Register(new TelegramTool());
toolRegistry.Register(new MailTool());
toolRegistry.Register(new DatabaseTool());
toolRegistry.Register(new FTPTool());
toolRegistry.Register(new DirectApiTool());
toolRegistry.Register(new WebFetchTool());
toolRegistry.Register(new WebMonitorTool());
toolRegistry.Register(new AgentCommTool());
toolRegistry.Register(new SocialMediaManagerTool());
toolRegistry.Register(new AgentSpawnTool());
toolRegistry.Register(new AgentEditorTool());
// ─── 6a. TelegramClient (MTProto User-API) ───
TelegramClientManager? tgClientManager = null;
if (instanceConfig.TelegramClient is not null)
{
tgClientManager = new TelegramClientManager(
instanceConfig,
instancePath,
loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient"));
toolRegistry.Register(new TelegramClientTool(tgClientManager));
coreLogger.LogInformation("TelegramClient-Tool registriert");
}
var permissionGate = new PermissionGate();
// TODO: Tools aus tools/ Ordner laden
// foreach (var toolDll in Directory.GetFiles("./tools", "*.dll"))
// LoadToolPlugin(toolDll, toolRegistry);
OpenRouterClient? openRouterClient = null;
AgentEngine? agentEngine = null;
AgentScheduler? agentScheduler = null;
ToolJobScheduler? toolJobScheduler = null;
if (!string.IsNullOrWhiteSpace(instanceConfig.OpenRouterApiKey))
{
openRouterClient = new OpenRouterClient(
instanceConfig.OpenRouterApiKey,
loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient"));
// ModelTypeConverter mit dem Client verbinden für PropertyGrid-Dropdown
ClawdDotNet.Models.ModelTypeConverter.Client = openRouterClient;
// ─── 6.1. StateStore initialisieren ───
var stateDbPath = Path.Combine(instancePath, "state.db");
var stateStore = new SqliteStateStore(stateDbPath);
agentEngine = new AgentEngine(openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory);
agentEngine.SetAgentConfigProvider(
() => instanceConfig.Agents,
instanceConfig.InstanceId,
agentId =>
{
var agent = instanceConfig.Agents.FirstOrDefault(a => a.AgentId == agentId);
if (agent is null) return null;
return string.IsNullOrWhiteSpace(agent.AgentDir) ? null : agent.AgentDir;
});
agentEngine.LoadPersistedChats();
agentScheduler = new AgentScheduler(agentEngine, instanceConfig.InstanceId, loggerFactory);
agentScheduler.RegisterAll(instanceConfig.Agents);
toolJobScheduler = new ToolJobScheduler(agentEngine, toolRegistry, stateStore, loggerFactory, instanceConfig.InstanceId);
toolJobScheduler.RegisterAll(instanceConfig.Agents);
coreLogger.LogInformation("AgentEngine und Scheduler erstellt, Chat-Verläufe geladen");
}
else
{
coreLogger.LogWarning("Kein OpenRouter API-Key konfiguriert Agenten sind deaktiviert");
}
// ─── 6b. TelegramClient verbinden (Session oder interaktiver Login) ───
if (tgClientManager is not null)
{
tgClientManager.OnLoginCodeRequired += async (prompt) =>
{
string? code = null;
await Task.Run(() =>
{
code = Microsoft.VisualBasic.Interaction.InputBox(
prompt, "Telegram Verifizierung", "");
});
return code ?? "";
};
tgClientManager.On2FAPasswordRequired += async () =>
{
string? pw = null;
await Task.Run(() =>
{
pw = Microsoft.VisualBasic.Interaction.InputBox(
"Bitte 2FA-Passwort eingeben:", "Telegram 2FA", "");
});
return pw ?? "";
};
try
{
tgClientManager.ConnectAsync(CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
coreLogger.LogError(ex, "Telegram: Login fehlgeschlagen");
}
}
// ─── 7. MainForm starten ───
var form = new frm_main(
settingsManager,
instanceConfig,
instancePath,
logDir,
loggerFactory,
toolRegistry,
dirManager,
agentEngine,
agentScheduler,
toolJobScheduler);
Application.Run(form);
// ─── 8. Aufräumen ───
coreLogger.LogInformation("ClawdDotNet wird beendet");
if (toolJobScheduler is not null)
toolJobScheduler.DisposeAsync().AsTask().GetAwaiter().GetResult();
if (agentScheduler is not null)
agentScheduler.DisposeAsync().AsTask().GetAwaiter().GetResult();
if (tgClientManager is not null)
tgClientManager.DisposeAsync().AsTask().GetAwaiter().GetResult();
openRouterClient?.Dispose();
loggerFactory.Dispose();
}
}
+123
View File
@@ -0,0 +1,123 @@
//------------------------------------------------------------------------------
// <auto-generated>
// Dieser Code wurde von einem Tool generiert.
// Laufzeitversion:4.0.30319.42000
//
// Änderungen an dieser Datei können falsches Verhalten verursachen und gehen verloren, wenn
// der Code erneut generiert wird.
// </auto-generated>
//------------------------------------------------------------------------------
namespace ClawdDotNet.Properties {
using System;
/// <summary>
/// Eine stark typisierte Ressourcenklasse zum Suchen von lokalisierten Zeichenfolgen usw.
/// </summary>
// Diese Klasse wurde von der StronglyTypedResourceBuilder automatisch generiert
// -Klasse über ein Tool wie ResGen oder Visual Studio automatisch generiert.
// Um einen Member hinzuzufügen oder zu entfernen, bearbeiten Sie die .ResX-Datei und führen dann ResGen
// mit der /str-Option erneut aus, oder Sie erstellen Ihr VS-Projekt neu.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Gibt die zwischengespeicherte ResourceManager-Instanz zurück, die von dieser Klasse verwendet wird.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("ClawdDotNet.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Überschreibt die CurrentUICulture-Eigenschaft des aktuellen Threads für alle
/// Ressourcenzuordnungen, die diese stark typisierte Ressourcenklasse verwenden.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap add {
get {
object obj = ResourceManager.GetObject("add", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap cog {
get {
object obj = ResourceManager.GetObject("cog", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap messenger {
get {
object obj = ResourceManager.GetObject("messenger", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap plus {
get {
object obj = ResourceManager.GetObject("plus", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap server_add {
get {
object obj = ResourceManager.GetObject("server_add", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap server_go {
get {
object obj = ResourceManager.GetObject("server_go", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="add" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\add.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="cog" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\cog.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="messenger" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\messenger.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="plus" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\plus.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="server_add" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\server_add.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="server_go" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\server_go.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+432
View File
@@ -0,0 +1,432 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Models;
namespace ClawdDotNet.Services;
/// <summary>
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
///
/// Layout:
/// {InstancesDir}/
/// ├── Instance-{Name}/
/// │ ├── InstanceSettings.json
/// │ ├── TokenUsage.json
/// │ └── Agents/
/// │ ├── AgentList.json
/// │ └── Agent-{Name}/
/// │ ├── AgentSettings.json
/// │ ├── Soul.md
/// │ ├── Identity.md
/// │ ├── Logs/
/// │ └── Workspace/
/// </summary>
public sealed class InstanceDirectoryManager
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly string _instancesDir;
private readonly Lock _tokenUsageLock = new();
public InstanceDirectoryManager(string instancesDirectory)
{
_instancesDir = Path.GetFullPath(instancesDirectory);
Directory.CreateDirectory(_instancesDir);
}
public string InstancesDirectory => _instancesDir;
// ═══════════════════════════════════════════════════
// INSTANZ-OPERATIONEN
// ═══════════════════════════════════════════════════
public static string BuildInstanceFolderName(string instanceName)
=> $"Instance-{SanitizeName(instanceName)}";
public string GetInstancePath(string instanceName)
=> Path.Combine(_instancesDir, BuildInstanceFolderName(instanceName));
public List<InstanceInfo> ListInstances()
{
var result = new List<InstanceInfo>();
if (!Directory.Exists(_instancesDir))
return result;
foreach (var dir in Directory.GetDirectories(_instancesDir, "Instance-*"))
{
var folderName = Path.GetFileName(dir);
var settingsPath = Path.Combine(dir, "InstanceSettings.json");
var info = new InstanceInfo
{
FolderName = folderName,
FolderPath = dir,
InstanceName = folderName.Replace("Instance-", "")
};
if (File.Exists(settingsPath))
{
try
{
var json = File.ReadAllText(settingsPath);
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts);
if (config is not null)
{
info.InstanceName = config.InstanceName;
info.ApiKeyStatus = string.IsNullOrWhiteSpace(config.OpenRouterApiKey)
? "Fehlt" : "Konfiguriert";
}
}
catch { /* defekte Config → Standardwerte */ }
}
// Agenten zählen
var agentsDir = Path.Combine(dir, "Agents");
if (Directory.Exists(agentsDir))
info.AgentCount = Directory.GetDirectories(agentsDir, "Agent-*").Length;
result.Add(info);
}
return result.OrderBy(i => i.InstanceName).ToList();
}
public string CreateInstance(string instanceName)
{
var instanceDir = GetInstancePath(instanceName);
if (Directory.Exists(instanceDir))
throw new InvalidOperationException($"Instanz '{instanceName}' existiert bereits.");
// Hauptverzeichnis
Directory.CreateDirectory(instanceDir);
// Agents-Unterverzeichnis
var agentsDir = Path.Combine(instanceDir, "Agents");
Directory.CreateDirectory(agentsDir);
// SharedWorkspace-Verzeichnis
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
// InstanceSettings.json
var config = new InstanceConfig
{
InstanceId = Guid.NewGuid().ToString("N")[..8],
InstanceName = instanceName,
LogDirectory = "./Logs",
WorkingDirectory = instanceDir
};
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
// TokenUsage.json (leer)
var tokenUsage = new TokenUsageFile
{
InstanceId = config.InstanceId,
InstanceName = instanceName
};
SaveJson(Path.Combine(instanceDir, "TokenUsage.json"), tokenUsage);
// AgentList.json (leer)
SaveJson(Path.Combine(agentsDir, "AgentList.json"), new AgentListFile());
return instanceDir;
}
public InstanceConfig LoadInstanceConfig(string instanceDir)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
if (!File.Exists(settingsPath))
throw new FileNotFoundException($"InstanceSettings.json nicht gefunden in: {instanceDir}");
var json = File.ReadAllText(settingsPath);
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
// Agenten aus Verzeichnisstruktur laden
config.Agents.Clear();
var agentsDir = Path.Combine(instanceDir, "Agents");
// Sicherstellen, dass SharedWorkspace existiert (Migration)
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
// AgentList.json für Descriptions laden
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
var agentList = File.Exists(agentListPath)
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
: new AgentListFile();
if (Directory.Exists(agentsDir))
{
foreach (var agentDir in Directory.GetDirectories(agentsDir, "Agent-*").OrderBy(d => d))
{
var agent = LoadAgentConfig(agentDir);
var folderName = Path.GetFileName(agentDir);
var listEntry = agentList.Agents.FirstOrDefault(a => a.FolderName == folderName);
if (listEntry is not null && !string.IsNullOrWhiteSpace(listEntry.Description))
agent.Description = listEntry.Description;
config.Agents.Add(agent);
}
}
return config;
}
public void SaveInstanceConfig(string instanceDir, InstanceConfig config)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
SaveJson(settingsPath, config);
}
// ═══════════════════════════════════════════════════
// AGENTEN-OPERATIONEN
// ═══════════════════════════════════════════════════
public static string BuildAgentFolderName(string agentName)
=> $"Agent-{SanitizeName(agentName)}";
public string GetAgentPath(string instanceDir, string agentName)
=> Path.Combine(instanceDir, "Agents", BuildAgentFolderName(agentName));
public string CreateAgent(string instanceDir, string agentName, string description = "")
{
var agentDir = GetAgentPath(instanceDir, agentName);
if (Directory.Exists(agentDir))
throw new InvalidOperationException($"Agent '{agentName}' existiert bereits in dieser Instanz.");
// Verzeichnisse anlegen
Directory.CreateDirectory(agentDir);
Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
// AgentSettings.json
var agentConfig = new AgentConfig
{
AgentId = SanitizeName(agentName).ToLowerInvariant(),
DisplayName = agentName,
Model = "anthropic/claude-sonnet-4-5"
};
SaveAgentSettings(agentDir, agentConfig);
// Identity.md
File.WriteAllText(Path.Combine(agentDir, "Identity.md"),
$"""
# Identity: {agentName}
Du bist **{agentName}**, ein spezialisierter KI-Agent im ClawdDotNet-System.
## Rolle
[Beschreibe hier die Rolle und Verantwortlichkeiten des Agenten]
## Expertise
[Beschreibe hier die Fachgebiete und Fähigkeiten]
## Kontext
[Beschreibe hier den Arbeitskontext und die Teamzugehörigkeit]
""");
// Soul.md
File.WriteAllText(Path.Combine(agentDir, "Soul.md"),
$"""
# Soul: {agentName}
## Persönlichkeit
- Gründlich und zuverlässig
- Klar und präzise in der Kommunikation
- Proaktiv bei der Problemerkennung
## Arbeitsweise
- Analysiere Aufgaben sorgfältig bevor du handelst
- Dokumentiere deine Entscheidungen und Ergebnisse
- Nutze die dir zugewiesenen Tools effizient
## Werte
- Genauigkeit vor Geschwindigkeit
- Transparenz in der Entscheidungsfindung
- Sicherheit und Datenschutz haben Priorität
""");
// AgentList.json aktualisieren
UpdateAgentList(instanceDir, agentName, description, BuildAgentFolderName(agentName));
return agentDir;
}
public AgentConfig LoadAgentConfig(string agentDir)
{
// AgentSettings.json laden
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
AgentConfig config;
if (File.Exists(settingsPath))
{
var json = File.ReadAllText(settingsPath);
config = JsonSerializer.Deserialize<AgentConfig>(json, JsonOpts) ?? new AgentConfig();
}
else
{
config = new AgentConfig
{
AgentId = Path.GetFileName(agentDir).Replace("Agent-", "").ToLowerInvariant(),
DisplayName = Path.GetFileName(agentDir).Replace("Agent-", "")
};
}
// Identity.md laden
var identityPath = Path.Combine(agentDir, "Identity.md");
if (File.Exists(identityPath))
config.Identity = File.ReadAllText(identityPath);
// Soul.md laden
var soulPath = Path.Combine(agentDir, "Soul.md");
if (File.Exists(soulPath))
config.Soul = File.ReadAllText(soulPath);
// Agent-Verzeichnis merken (stabil auch bei DisplayName-Änderungen)
config.AgentDir = Path.GetFullPath(agentDir);
// Workspace-Pfad setzen
config.WorkspacePath = Path.GetFullPath(Path.Combine(agentDir, "Workspace"));
// SharedWorkspace-Pfad setzen
var agentsDir = Path.GetDirectoryName(agentDir); // Dies ist der /Agents Ordner
if (agentsDir != null)
{
config.SharedWorkspacePath = Path.GetFullPath(Path.Combine(agentsDir, "SharedWorkspace"));
}
return config;
}
public void SaveAgentSettings(string agentDir, AgentConfig config)
{
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
SaveJson(settingsPath, config);
}
public void SaveAgentIdentity(string agentDir, string identity)
{
File.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity);
}
public void SaveAgentSoul(string agentDir, string soul)
{
File.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul);
}
public void RemoveAgent(string instanceDir, string agentFolderName)
{
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
if (Directory.Exists(agentDir))
Directory.Delete(agentDir, recursive: true);
// AgentList.json aktualisieren
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);
}
}
// ═══════════════════════════════════════════════════
// TOKEN USAGE
// ═══════════════════════════════════════════════════
public void AppendTokenUsage(string instanceDir, TokenUsageRecord record)
{
lock (_tokenUsageLock)
{
var path = Path.Combine(instanceDir, "TokenUsage.json");
TokenUsageFile file;
if (File.Exists(path))
{
try
{
file = LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile();
}
catch (JsonException)
{
// Korrupte Datei: Backup erstellen, neu anfangen
var backupPath = path + $".corrupt_{DateTime.Now:yyyyMMdd_HHmmss}";
try { File.Copy(path, backupPath, overwrite: true); } catch { /* best effort */ }
file = new TokenUsageFile();
}
}
else
{
file = new TokenUsageFile();
}
file.Records.Add(record);
SaveJson(path, file);
}
}
public TokenUsageFile LoadTokenUsage(string instanceDir)
{
var path = Path.Combine(instanceDir, "TokenUsage.json");
return File.Exists(path)
? LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile()
: new TokenUsageFile();
}
// ═══════════════════════════════════════════════════
// HILFSMETHODEN
// ═══════════════════════════════════════════════════
private void UpdateAgentList(string instanceDir, string agentName, string description, string folderName)
{
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
var list = File.Exists(agentListPath)
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
: new AgentListFile();
// Duplikat-Check
if (list.Agents.All(a => a.FolderName != folderName))
{
list.Agents.Add(new AgentListItem
{
Name = agentName,
Description = description,
FolderName = folderName
});
}
SaveJson(agentListPath, list);
}
private static void SaveJson<T>(string path, T obj)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(obj, JsonOpts);
File.WriteAllText(path, json);
}
private static T? LoadJson<T>(string path)
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, JsonOpts);
}
private static string SanitizeName(string name)
{
var sanitized = name.Trim();
foreach (var c in Path.GetInvalidFileNameChars())
sanitized = sanitized.Replace(c, '_');
return sanitized.Replace(' ', '_');
}
}
+95
View File
@@ -0,0 +1,95 @@
using System.Text.Json;
using ClawdDotNet.Models;
namespace ClawdDotNet.Services;
/// <summary>
/// Thread-safe JSON persistence for job execution history.
/// Uses file locking to handle concurrent access from multiple schedulers.
/// </summary>
public sealed class JobHistoryService
{
private readonly string _filePath;
private readonly Lock _lock = new();
private readonly int _maxEntries;
private List<JobHistoryEntry> _entries = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public JobHistoryService(string instancePath, int maxEntries = 500)
{
_filePath = Path.Combine(instancePath, "job_history.json");
_maxEntries = maxEntries;
Load();
}
/// <summary>
/// Adds a new entry to the history (thread-safe, persists immediately).
/// </summary>
public void Add(JobHistoryEntry entry)
{
lock (_lock)
{
_entries.Insert(0, entry); // newest first
// Trim old entries
if (_entries.Count > _maxEntries)
_entries = _entries.Take(_maxEntries).ToList();
Save();
}
}
/// <summary>
/// Returns a snapshot of all entries (newest first).
/// </summary>
public List<JobHistoryEntry> GetAll()
{
lock (_lock)
return new List<JobHistoryEntry>(_entries);
}
private void Load()
{
lock (_lock)
{
try
{
if (!File.Exists(_filePath))
{
_entries = new List<JobHistoryEntry>();
return;
}
using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
_entries = JsonSerializer.Deserialize<List<JobHistoryEntry>>(stream, JsonOptions)
?? new List<JobHistoryEntry>();
}
catch
{
_entries = new List<JobHistoryEntry>();
}
}
}
private void Save()
{
try
{
var tmpPath = _filePath + ".tmp";
using (var stream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
JsonSerializer.Serialize(stream, _entries, JsonOptions);
}
File.Move(tmpPath, _filePath, overwrite: true);
}
catch
{
// Silently ignore write failures — next save will retry
}
}
}
+255
View File
@@ -0,0 +1,255 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace ClawdDotNet.Services;
/// <summary>
/// Überwacht das Logs-Verzeichnis und liefert neue Log-Einträge gefiltert
/// an eine RichTextBox. Bereinigt die RichTextBox automatisch wenn sie
/// zu voll wird, damit das UI reaktionsfähig bleibt.
///
/// Struktur: Logs/{Datum}/{Modul}.log
/// Log-Format: [{Timestamp}] [{LEVEL}] {Message}
/// </summary>
public sealed class LiveLogViewerService : IDisposable
{
private readonly string _logDirectory;
private readonly RichTextBox _target;
private readonly System.Windows.Forms.Timer _refreshTimer;
private readonly int _maxLines;
// Tracking: pro Datei die letzte gelesene Position
private readonly ConcurrentDictionary<string, long> _filePositions = new();
// Filter
private string _moduleFilter = ""; // leer = alle
private string _levelFilter = ""; // leer = alle
private static readonly Regex LevelRegex = new(
@"\[(INF|WRN|ERR|DBG)\]",
RegexOptions.Compiled);
public LiveLogViewerService(string logDirectory, RichTextBox target, int refreshIntervalMs = 500, int maxLines = 2000)
{
_logDirectory = logDirectory;
_target = target;
_maxLines = maxLines;
_refreshTimer = new System.Windows.Forms.Timer { Interval = refreshIntervalMs };
_refreshTimer.Tick += OnTimerTick;
}
public void Start() => _refreshTimer.Start();
public void Stop() => _refreshTimer.Stop();
public void SetModuleFilter(string module)
{
_moduleFilter = module;
ClearAndResetPositions();
}
public void SetLevelFilter(string level)
{
_levelFilter = level;
ClearAndResetPositions();
}
/// <summary>
/// Gibt alle erkannten Modul-Namen zurück (basierend auf den vorhandenen .log-Dateien).
/// </summary>
public List<string> GetAvailableModules()
{
var modules = new HashSet<string> { "Alle" };
if (!Directory.Exists(_logDirectory))
return modules.ToList();
// Alle Unterordner (Datum-Ordner) durchsuchen
foreach (var dateDir in Directory.GetDirectories(_logDirectory))
{
foreach (var logFile in Directory.GetFiles(dateDir, "*.log"))
{
var name = Path.GetFileNameWithoutExtension(logFile);
modules.Add(name);
}
}
return modules.OrderBy(m => m == "Alle" ? "" : m).ToList();
}
private void OnTimerTick(object? sender, EventArgs e)
{
try
{
ReadNewEntries();
}
catch
{
// Logging-Viewer darf niemals das UI crashen
}
}
private void ReadNewEntries()
{
if (!Directory.Exists(_logDirectory))
return;
// Heutiges Datum-Verzeichnis (und ggf. gestriges für Logs um Mitternacht)
var today = DateTime.Now.ToString("yyyy-MM-dd");
var todayDir = Path.Combine(_logDirectory, today);
if (!Directory.Exists(todayDir))
return;
var logFiles = Directory.GetFiles(todayDir, "*.log");
var newLines = new List<(DateTime time, string line, string module)>();
foreach (var filePath in logFiles)
{
var moduleName = Path.GetFileNameWithoutExtension(filePath);
// Modul-Filter
if (!string.IsNullOrEmpty(_moduleFilter) && _moduleFilter != "Alle"
&& !string.Equals(moduleName, _moduleFilter, StringComparison.OrdinalIgnoreCase))
continue;
var lastPos = _filePositions.GetOrAdd(filePath, 0L);
try
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
if (fs.Length < lastPos)
{
// Datei wurde rotiert/gekürzt
lastPos = 0;
}
if (fs.Length == lastPos)
continue;
fs.Seek(lastPos, SeekOrigin.Begin);
using var reader = new StreamReader(fs);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line))
continue;
// Level-Filter
if (!string.IsNullOrEmpty(_levelFilter) && _levelFilter != "Alle")
{
if (!PassesLevelFilter(line))
continue;
}
newLines.Add((DateTime.Now, $"[{moduleName}] {line}", moduleName));
}
_filePositions[filePath] = fs.Position;
}
catch (IOException)
{
// Datei wird gerade geschrieben - nächstes Mal versuchen
}
}
if (newLines.Count == 0)
return;
// In UI schreiben
AppendToRichTextBox(newLines);
}
private bool PassesLevelFilter(string line)
{
var match = LevelRegex.Match(line);
if (!match.Success)
return true; // Unbekanntes Format durchlassen
var level = match.Groups[1].Value;
return _levelFilter switch
{
"Info" => level is "INF" or "WRN" or "ERR",
"Warn" => level is "WRN" or "ERR",
"Error" => level is "ERR",
_ => true
};
}
private void AppendToRichTextBox(List<(DateTime time, string line, string module)> lines)
{
if (_target.IsDisposed || !_target.IsHandleCreated)
return;
_target.BeginInvoke(() =>
{
_target.SuspendLayout();
foreach (var (_, line, module) in lines)
{
var color = GetColorForLine(line);
_target.SelectionStart = _target.TextLength;
_target.SelectionLength = 0;
_target.SelectionColor = color;
_target.AppendText(line + Environment.NewLine);
}
// Bereinigung: wenn zu viele Zeilen, die ältesten entfernen
TrimIfNeeded();
// Auto-Scroll zum Ende
_target.SelectionStart = _target.TextLength;
_target.ScrollToCaret();
_target.ResumeLayout();
});
}
private static Color GetColorForLine(string line)
{
if (line.Contains("[ERR]"))
return Color.Red;
if (line.Contains("[WRN]"))
return Color.Orange;
if (line.Contains("[DBG]"))
return Color.Gray;
return Color.LightGreen; // INF
}
private void TrimIfNeeded()
{
if (_target.Lines.Length <= _maxLines)
return;
// Die älteste Hälfte entfernen
var removeCount = _maxLines / 2;
var removeEndIndex = _target.GetFirstCharIndexFromLine(removeCount);
if (removeEndIndex <= 0)
return;
_target.SelectionStart = 0;
_target.SelectionLength = removeEndIndex;
_target.SelectedText = $"--- {removeCount} ältere Zeilen entfernt ---{Environment.NewLine}";
}
private void ClearAndResetPositions()
{
_filePositions.Clear();
if (_target.IsHandleCreated && !_target.IsDisposed)
{
_target.BeginInvoke(() =>
{
_target.Clear();
});
}
}
public void Dispose()
{
_refreshTimer.Stop();
_refreshTimer.Dispose();
}
}
+212
View File
@@ -0,0 +1,212 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Text.Json;
namespace ClawdDotNet.Services;
public sealed class OpenRouterStatusService : IDisposable
{
private readonly HttpClient _http;
private readonly System.Windows.Forms.Timer _timer;
private readonly ConcurrentBag<UsageRecord> _usageRecords = new();
// Preise pro 1M Token (Input / Output) in USD — gängige OpenRouter-Modelle
private static readonly Dictionary<string, (double InputPer1M, double OutputPer1M)> ModelPricing = new(StringComparer.OrdinalIgnoreCase)
{
["anthropic/claude-sonnet-4"] = (3.00, 15.00),
["anthropic/claude-haiku-4.5"] = (0.80, 4.00),
["anthropic/claude-opus-4"] = (15.00, 75.00),
["openai/gpt-4o"] = (2.50, 10.00),
["openai/gpt-4o-mini"] = (0.15, 0.60),
["openai/gpt-4.1"] = (2.00, 8.00),
["openai/gpt-4.1-mini"] = (0.40, 1.60),
["openai/gpt-4.1-nano"] = (0.10, 0.40),
["google/gemini-2.5-flash"] = (0.15, 0.60),
["google/gemini-2.5-pro"] = (1.25, 10.00),
["google/gemini-3.1-flash-lite"] = (0.00, 0.00),
["deepseek/deepseek-chat-v3-0324"] = (0.14, 0.28),
};
private const double UsdToEur = 0.92;
public bool IsApiReachable { get; private set; }
public string StatusText { get; private set; } = "Prüfe...";
public string CreditsText { get; private set; } = "—";
public string CreditsTooltip { get; private set; } = "";
public double? CreditBalance { get; private set; }
public double? CreditRemaining { get; private set; }
public event Action? OnStatusUpdated;
public OpenRouterStatusService(string apiKey, string baseUrl = "https://openrouter.ai/api/v1/", int checkIntervalSeconds = 60)
{
_http = new HttpClient { BaseAddress = new Uri(baseUrl) };
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
_timer = new System.Windows.Forms.Timer { Interval = checkIntervalSeconds * 1000 };
_timer.Tick += async (_, _) => await CheckStatusAsync();
}
public void Start()
{
_timer.Start();
_ = CheckStatusAsync();
}
public void Stop() => _timer.Stop();
public void RecordUsage(string model, int promptTokens, int completionTokens)
{
var cost = CalculateCost(model, promptTokens, completionTokens);
_usageRecords.Add(new UsageRecord(DateTime.Now, model, promptTokens, completionTokens, cost));
UpdateCreditsText();
OnStatusUpdated?.Invoke();
}
private static double CalculateCost(string model, int promptTokens, int completionTokens)
{
if (!ModelPricing.TryGetValue(model, out var pricing))
return 0;
return (promptTokens / 1_000_000.0 * pricing.InputPer1M) +
(completionTokens / 1_000_000.0 * pricing.OutputPer1M);
}
private async Task CheckStatusAsync()
{
try
{
using var response = await _http.GetAsync("auth/key");
if (response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
IsApiReachable = true;
if (doc.RootElement.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("limit", out var limit))
CreditBalance = limit.GetDouble();
if (data.TryGetProperty("usage", out var usage))
{
var used = usage.GetDouble();
var remaining = (CreditBalance ?? 0) - used;
CreditRemaining = remaining;
StatusText = $"✓ API OK | Credits: ${remaining:F4} von ${CreditBalance:F2}";
}
else
{
StatusText = "✓ API erreichbar";
}
}
else
{
StatusText = "✓ API erreichbar";
}
}
else
{
IsApiReachable = false;
StatusText = $"✗ API Fehler: {(int)response.StatusCode}";
}
}
catch (HttpRequestException ex)
{
IsApiReachable = false;
StatusText = $"✗ Nicht erreichbar: {ex.Message}";
}
catch (TaskCanceledException)
{
IsApiReachable = false;
StatusText = "✗ Timeout";
}
catch (Exception ex)
{
IsApiReachable = false;
StatusText = $"✗ Fehler: {ex.Message}";
}
UpdateCreditsText();
OnStatusUpdated?.Invoke();
}
private void UpdateCreditsText()
{
var now = DateTime.Now;
var oneHourAgo = now.AddHours(-1);
var oneDayAgo = now.AddHours(-24);
var records = _usageRecords.ToArray();
var lastHour = records.Where(r => r.Timestamp >= oneHourAgo).ToArray();
var last24h = records.Where(r => r.Timestamp >= oneDayAgo).ToArray();
var tokensLastHour = lastHour.Sum(r => r.PromptTokens + r.CompletionTokens);
var tokensLast24h = last24h.Sum(r => r.PromptTokens + r.CompletionTokens);
var costLastHour = lastHour.Sum(r => r.CostUsd);
var costLast24h = last24h.Sum(r => r.CostUsd);
CreditsText = $"1h: {tokensLastHour:N0} Tok (~{costLastHour * UsdToEur:F4}€) | " +
$"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€)";
// Detaillierter Tooltip: Pro-Model-Aufschlüsselung (letzte 24h)
var modelGroups = last24h
.GroupBy(r => r.Model)
.OrderByDescending(g => g.Sum(r => r.CostUsd))
.ToList();
if (modelGroups.Count == 0)
{
CreditsTooltip = "Keine Token-Nutzung in den letzten 24h";
return;
}
var sb = new System.Text.StringBuilder();
sb.AppendLine("═══ Token-Verbrauch (24h) ═══");
sb.AppendLine();
foreach (var group in modelGroups)
{
var modelName = group.Key;
var shortName = modelName.Contains('/') ? modelName[(modelName.IndexOf('/') + 1)..] : modelName;
var prompt = group.Sum(r => r.PromptTokens);
var completion = group.Sum(r => r.CompletionTokens);
var total = prompt + completion;
var cost = group.Sum(r => r.CostUsd);
var runs = group.Count();
sb.AppendLine($"▸ {shortName}");
sb.AppendLine($" {runs}x Runs | {total:N0} Tokens ({prompt:N0} in / {completion:N0} out)");
if (ModelPricing.TryGetValue(modelName, out var pricing))
sb.AppendLine($" Preis: ${pricing.InputPer1M}/1M in, ${pricing.OutputPer1M}/1M out");
sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)");
sb.AppendLine();
}
var totalCost = last24h.Sum(r => r.CostUsd);
sb.AppendLine($"═══ Gesamt: ${totalCost:F4} (~{totalCost * UsdToEur:F4}€) ═══");
CreditsTooltip = sb.ToString().TrimEnd();
}
public void Dispose()
{
_timer.Stop();
_timer.Dispose();
_http.Dispose();
}
private sealed record UsageRecord(
DateTime Timestamp,
string Model,
int PromptTokens,
int CompletionTokens,
double CostUsd);
}
+68
View File
@@ -0,0 +1,68 @@
using System.Text.Json;
using ClawdDotNet.Models;
namespace ClawdDotNet.Services;
public sealed class SettingsManager
{
private const string SettingsFileName = "Settings.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true
};
private readonly string _settingsPath;
public AppSettings AppSettings { get; private set; } = new();
public SettingsManager(string? basePath = null)
{
var dir = basePath ?? AppDomain.CurrentDomain.BaseDirectory;
_settingsPath = Path.Combine(dir, SettingsFileName);
}
public void Load()
{
if (!File.Exists(_settingsPath))
{
AppSettings = new AppSettings();
Save(); // Defaults schreiben
return;
}
try
{
var json = File.ReadAllText(_settingsPath);
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? new AppSettings();
}
catch
{
AppSettings = new AppSettings();
}
}
public void Save()
{
try
{
var dir = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
File.WriteAllText(_settingsPath, json);
}
catch (Exception ex)
{
// Logging ist hier ggf. noch nicht verfügbar Fallback auf MessageBox
MessageBox.Show(
$"Settings konnten nicht gespeichert werden:\n{ex.Message}",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.UI;
public sealed record BridgeMessage(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("agentId")] string? AgentId = null,
[property: JsonPropertyName("content")] string? Content = null,
[property: JsonPropertyName("status")] string? Status = null,
[property: JsonPropertyName("stepCount")] int? StepCount = null,
[property: JsonPropertyName("tokenCount")] int? TokenCount = null,
[property: JsonPropertyName("error")] string? Error = null,
[property: JsonPropertyName("extra")] object? Extra = null
);
public static class BridgeTypes
{
// C# → Browser
public const string AgentListUpdate = "agent_list_update";
public const string AgentStatusUpdate = "agent_status";
public const string SelectAgent = "select_agent";
public const string ChatMessage = "chat_message";
public const string ChatTyping = "chat_typing";
public const string ChatHistory = "chat_history";
public const string RunStarted = "run_started";
public const string RunFinished = "run_finished";
// Browser → C#
public const string UserMessage = "user_message";
public const string OpenAgentChat = "open_agent_chat";
public const string RunNow = "run_now";
public const string AbortRun = "abort_run";
}
+40
View File
@@ -0,0 +1,40 @@
using System.Reflection;
namespace ClawdDotNet.UI;
public static class EmbeddedUiManager
{
private static string? _extractedPath;
public static string ExtractToTemp()
{
if (_extractedPath is not null) return _extractedPath;
var tempDir = Path.Combine(Path.GetTempPath(), "ClawdDotNet_UI",
Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "dev");
Directory.CreateDirectory(tempDir);
var asm = Assembly.GetExecutingAssembly();
var prefix = "ClawdDotNet.EmbeddedUI.";
foreach (var name in asm.GetManifestResourceNames()
.Where(n => n.StartsWith(prefix)))
{
// "ClawdDotNet.EmbeddedUI.chat.html" → "chat.html"
var fileName = name[prefix.Length..];
var dest = Path.Combine(tempDir, fileName);
using var stream = asm.GetManifestResourceStream(name)!;
using var file = File.Create(dest);
stream.CopyTo(file);
}
_extractedPath = tempDir;
return tempDir;
}
public static string GetExtractedPath()
=> _extractedPath ?? throw new InvalidOperationException(
"EmbeddedUiManager.ExtractToTemp() must be called first.");
}
+75
View File
@@ -0,0 +1,75 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Web.WebView2.Core;
namespace ClawdDotNet.UI;
public sealed class WebViewBridge : IDisposable
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly Microsoft.Web.WebView2.WinForms.WebView2 _wv;
private readonly ILogger _logger;
public event Action<BridgeMessage>? MessageReceived;
public WebViewBridge(
Microsoft.Web.WebView2.WinForms.WebView2 webView,
ILogger logger)
{
_wv = webView;
_logger = logger;
_wv.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
}
public async Task SendAsync(BridgeMessage message)
{
var json = JsonSerializer.Serialize(message, JsonOpts);
var script = $"window.__bridge?.receive({json})";
if (_wv.InvokeRequired)
{
await Task.Factory.FromAsync(
_wv.BeginInvoke(new Func<Task>(async () =>
await _wv.CoreWebView2.ExecuteScriptAsync(script))),
_ => { });
}
else
{
await _wv.CoreWebView2.ExecuteScriptAsync(script);
}
}
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
try
{
var raw = e.TryGetWebMessageAsString();
_logger.LogDebug("Bridge raw incoming: {Raw}", raw);
if (string.IsNullOrWhiteSpace(raw)) return;
var msg = JsonSerializer.Deserialize<BridgeMessage>(raw, JsonOpts);
_logger.LogInformation("Bridge received: type={Type}, agentId={AgentId}, content={Content}",
msg?.Type, msg?.AgentId, msg?.Content?.Length > 50 ? msg.Content[..50] + "..." : msg?.Content);
if (msg is not null)
MessageReceived?.Invoke(msg);
}
catch (Exception ex)
{
_logger.LogError(ex, "Bridge: failed to deserialize incoming message");
}
}
public void Dispose()
{
if (_wv.CoreWebView2 is not null)
_wv.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"instanceId": "crypto-01",
"instanceName": "Krypto-Team",
"openRouterApiKey": "sk-or-DEIN-API-KEY-HIER",
"workingDirectory": "./data/crypto/",
"logDirectory": "./Logs",
"webServerPort": 8082,
"agents": [
{
"agentId": "crypto-analyst",
"displayName": "Krypto-Analyst",
"model": "anthropic/claude-sonnet-4-5",
"systemPrompt": "Du analysierst Kryptowährungsmärkte und erstellst Trading-Signale.",
"tools": {
"Database": {
"connectionString": "Server=localhost;Database=crypto;User=agent_crypto;Password=changeme;",
"type": "mysql",
"allowedTables": ["prices", "signals", "portfolio"]
},
"FileRW": {
"rootPath": "./data/crypto/reports/",
"allowWrite": true,
"allowedExtensions": [".json", ".txt", ".md"]
},
"Mail": {
"imapHost": "imap.example.com",
"imapPort": 993,
"smtpHost": "smtp.example.com",
"smtpPort": 587,
"username": "crypto-alerts@example.com",
"password": "changeme",
"allowedRecipients": ["owner@example.com"]
}
},
"scheduler": {
"cron": "*/30 * * * *",
"runOnStart": true
},
"loopGuard": {
"maxSteps": 30,
"maxTokens": 120000,
"timeoutSeconds": 900
}
}
]
}
+55
View File
@@ -0,0 +1,55 @@
{
"instanceId": "stock-01",
"instanceName": "Aktien-Team",
"openRouterApiKey": "sk-or-DEIN-API-KEY-HIER",
"workingDirectory": "./data/stock/",
"logDirectory": "./Logs",
"webServerPort": 8081,
"agents": [
{
"agentId": "market-analyst",
"displayName": "Marktanalyse",
"model": "anthropic/claude-sonnet-4-5",
"systemPrompt": "Du bist ein erfahrener Marktanalyst. Analysiere Marktdaten und erstelle Berichte.",
"tools": {
"Database": {
"connectionString": "Server=localhost;Database=stocks;User=agent_analyst;Password=changeme;",
"type": "mysql",
"allowedTables": ["quotes", "indicators", "news"]
},
"FileRW": {
"rootPath": "./data/stock/analyst/",
"allowWrite": true,
"allowedExtensions": [".json", ".txt", ".md"]
}
},
"scheduler": {
"cron": "0 7 * * 1-5",
"runOnStart": false
},
"loopGuard": {
"maxSteps": 25,
"maxTokens": 100000,
"timeoutSeconds": 600
}
},
{
"agentId": "webdev",
"displayName": "Web-Entwickler",
"model": "google/gemini-flash-1.5",
"systemPrompt": "Du erstellst HTML-Dashboards aus bereitgestellten Daten.",
"tools": {
"FileRW": {
"rootPath": "./data/stock/wwwroot/",
"allowWrite": true,
"allowedExtensions": [".html", ".css", ".js", ".json"]
}
},
"loopGuard": {
"maxSteps": 10,
"maxTokens": 40000,
"timeoutSeconds": 300
}
}
]
}
+503
View File
@@ -0,0 +1,503 @@
# ClawdDotNet Instanz-Setup-Guide
Anleitung für die vollständige Planung, Erstellung und Konfiguration einer neuen ClawdDotNet-Instanz. Richtet sich an menschliche Nutzer und AI-Agenten gleichermaßen.
---
## 1. Planung: Was soll die Instanz leisten?
Bevor eine Instanz erstellt wird, muss klar definiert werden:
| Frage | Beispiel |
|-------|---------|
| Welche Aufgabe(n) soll die Instanz als Ganzes erledigen? | "Social-Media-Monitoring mit automatischer Zusammenfassung" |
| Wie viele Agenten werden benötigt? | 15 (mehr = höhere Koordinationskosten) |
| Welche externen Systeme sind beteiligt? | Telegram, E-Mail, Datenbank, APIs |
| Wie hoch ist das Token-Budget pro Tag? | z.B. $1/Tag, $10/Tag |
| Soll die Instanz autonom arbeiten oder manuell getriggert werden? | Cron-Jobs vs. manueller Start |
### Agentenanzahl richtig wählen
| Szenario | Empfehlung |
|----------|-----------|
| Eine klar abgegrenzte Aufgabe | 1 Agent |
| Aufgabe mit getrennten Zuständigkeiten (z.B. Recherche + Bericht) | 23 Agenten |
| Komplexes System mit Koordination | 35 Agenten, davon 1 Koordinator |
| Mehr als 5 Agenten | Kritisch hinterfragen — Koordinationsoverhead steigt quadratisch |
**Faustregel:** Jeder Agent, der mit einem anderen kommuniziert, kostet Token auf beiden Seiten. Zwei Agenten, die jeweils 3 Nachrichten austauschen = 6 LLM-Runs.
---
## 2. Verzeichnisstruktur einer Instanz
Eine Instanz hat folgende Struktur. `CreateInstance()` und `CreateAgent()` im InstanceDirectoryManager erzeugen diese automatisch.
```
Instance-{Name}/
├── InstanceSettings.json ← Instanz-Konfiguration + API-Key
├── TokenUsage.json ← Verbrauchsprotokoll (leer bei Start)
├── state.db ← SQLite StateStore (wird zur Laufzeit erzeugt)
└── Agents/
├── AgentList.json ← Agenten-Register (Name, Beschreibung, Ordner)
├── SharedWorkspace/ ← Geteilter Arbeitsbereich
│ └── coordination/ ← Status-Dateien für Inter-Agenten-Kommunikation
└── Agent-{Name}/
├── AgentSettings.json ← Agent-Konfiguration (Modell, Tools, Scheduler)
├── Identity.md ← WER ist der Agent (Rolle, Expertise)
├── Soul.md ← WIE arbeitet der Agent (Persönlichkeit, Methodik)
├── ChatContext.json ← Aktueller Konversationskontext
├── ChatHistory.json ← Vollständiger Chatverlauf
├── Logs/ ← Agent-spezifische Logs
└── Workspace/ ← Persönlicher Arbeitsbereich
```
---
## 3. Schritt-für-Schritt: Instanz erstellen
### 3.1 Instanz anlegen
Über die UI: **Instanz-Manager → Neue Instanz erstellen**
Oder manuell die Ordnerstruktur erzeugen. Die `InstanceSettings.json` hat dieses Format:
```json
{
"instanceId": "<8-Zeichen-GUID>",
"instanceName": "MeinProjekt",
"openRouterApiKey": "",
"workingDirectory": "",
"logDirectory": "./Logs",
"webServerPort": 8080,
"agents": [],
"services": []
}
```
**Wichtig:** Der `openRouterApiKey` muss gesetzt werden, bevor Agenten funktionieren.
### 3.2 Agenten anlegen
Pro Agent wird ein Ordner `Agent-{Name}` unter `Agents/` erstellt.
**AgentList.json** — Register aller Agenten:
```json
{
"agents": [
{
"name": "Analyst",
"description": "Recherchiert und analysiert Daten aus externen Quellen",
"folderName": "Agent-Analyst"
},
{
"name": "Reporter",
"description": "Erstellt Berichte und versendet sie per E-Mail",
"folderName": "Agent-Reporter"
}
]
}
```
### 3.3 AgentSettings.json
Jeder Agent bekommt eine `AgentSettings.json`:
```json
{
"agentId": "analyst",
"displayName": "Analyst",
"model": "google/gemini-2.5-flash",
"systemPrompt": "",
"tools": {
"WebFetch": {
"allowedDomains": ["example.com", "api.example.com"]
},
"FileRW": {
"sharedAccessLevel": "ReadWrite",
"personalAllowedExtensions": [".txt", ".json", ".md", ".csv"],
"sharedAllowedExtensions": [".txt", ".json", ".md"]
}
},
"scheduler": {
"cron": "0 8 * * 1-5",
"runOnStart": false,
"taskMessage": "Recherchiere die neuesten Daten und speichere sie im SharedWorkspace."
},
"toolJobs": [],
"loopGuard": {
"maxSteps": 15,
"maxTokens": 50000,
"timeoutSeconds": 300,
"maxContextTokens": 100000,
"compactionThreshold": 0.8
}
}
```
### 3.4 Identity.md schreiben
Definiert **WER** der Agent ist. Kurz, prägnant, rollenspezifisch.
```markdown
# Analyst
Du bist ein Datenanalyst im ClawdDotNet-Team "MeinProjekt".
## Rolle
- Recherche und Aufbereitung externer Daten
- Ergebnisse im SharedWorkspace als JSON ablegen
## Expertise
- Web-Recherche, Datenextraktion, strukturierte Zusammenfassungen
## Kontext
- Deine Ergebnisse werden vom Reporter-Agenten weiterverarbeitet
- Lege Dateien im SharedWorkspace unter coordination/ ab
```
### 3.5 Soul.md schreiben
Definiert **WIE** der Agent arbeitet.
```markdown
# Arbeitsweise
## Persönlichkeit
- Gründlich und faktenbasiert
- Komprimiert Informationen auf das Wesentliche
## Methodik
1. Quellen prüfen und Daten abrufen
2. Relevante Informationen extrahieren
3. Strukturiertes JSON erstellen
4. Im SharedWorkspace ablegen
## Ausgabeformat
- Ergebnisse immer als JSON mit Timestamp
- Keine Prosa, nur strukturierte Daten
```
---
## 4. Modellwahl: Kosten vs. Leistung
Die Modellwahl hat den größten Einfluss auf die laufenden Kosten. Nicht jeder Agent braucht das teuerste Modell.
### Modell-Empfehlungen nach Aufgabentyp
| Aufgabentyp | Empfohlenes Modell | Kosten (ca.) | Begründung |
|-------------|-------------------|-------------|------------|
| **Einfache Routineaufgaben** (Dateien verschieben, Status prüfen, weiterleiten) | `google/gemini-2.5-flash` | ~$0.15/M input | Schnell, günstig, zuverlässiger Tool-Use |
| **Textverarbeitung** (Zusammenfassungen, Berichte, E-Mails) | `google/gemini-2.5-flash` oder `anthropic/claude-haiku-4-5` | $0.150.80/M input | Gutes Preis/Leistungs-Verhältnis |
| **Analyse & Reasoning** (Datenanalyse, Entscheidungen, komplexe Logik) | `anthropic/claude-sonnet-4-5` | ~$3/M input | Starkes Reasoning bei akzeptablen Kosten |
| **Koordinator-Agent** (orchestriert andere, trifft Entscheidungen) | `anthropic/claude-sonnet-4-5` | ~$3/M input | Braucht gutes Verständnis der Gesamtsituation |
| **Maximale Qualität** (kritische Entscheidungen, kreative Aufgaben) | `anthropic/claude-opus-4` | ~$15/M input | Nur wenn Qualität wichtiger als Kosten |
| **Budget-Minimum** | `google/gemini-2.5-flash-lite` | ~$0.02/M input | Für einfachste Aufgaben, schlechterer Tool-Use |
### Kostenberechnung
```
Kosten pro Run ≈ (Input-Tokens × Inputpreis) + (Output-Tokens × Outputpreis)
Beispiel: Gemini 2.5 Flash, typischer Run (5000 in, 1000 out):
= 5000 × $0.00000015 + 1000 × $0.0000006
= $0.00075 + $0.0006
= $0.00135 pro Run
Beispiel: Claude Sonnet, typischer Run (5000 in, 1000 out):
= 5000 × $0.000003 + 1000 × $0.000015
= $0.015 + $0.015
= $0.03 pro Run
```
**Cron alle 5 Minuten mit Gemini Flash:** ~288 Runs/Tag × $0.00135 = **~$0.39/Tag**
**Cron alle 5 Minuten mit Claude Sonnet:** ~288 Runs/Tag × $0.03 = **~$8.64/Tag**
### LoopGuard für Kostenkontrolle anpassen
| Szenario | maxSteps | maxTokens | timeoutSeconds |
|----------|----------|-----------|----------------|
| Einfache Routine | 510 | 20.000 | 120 |
| Standard-Aufgabe | 1020 | 50.000 | 300 |
| Komplexe Analyse | 2030 | 80.000 | 600 |
| Budget-kritisch | 35 | 10.000 | 60 |
---
## 5. Tool-Zuweisung
### Verfügbare Tools
| Tool | Zweck | Benötigte Config | Background Jobs |
|------|-------|-----------------|-----------------|
| **FileRW** | Dateien lesen/schreiben im Workspace | (optional) sharedAccessLevel, allowedExtensions | Nein |
| **Database** | SQL/NoSQL-Zugriff | type, connectionString | Nein |
| **Mail** | E-Mails senden/empfangen | smtpHost, imapHost, username, password | Ja: `mail_check_unread` |
| **Telegram** | Telegram-Nachrichten | botToken | Ja: `telegram_poll` |
| **WebFetch** | Webseiten/RSS abrufen | allowedDomains | Nein |
| **WebMonitor** | Webseiten auf Änderungen prüfen | monitors (mit url, parser, idPattern) | Nein |
| **FTP** | Datei-Upload/Download | host | Nein |
| **DirectAPI** | Finanzdaten (Aktien, Crypto, Forex) | providers (mit apiKey pro Provider) | Nein |
| **SocialMediaManager** | X, Reddit, YouTube-Transkripte | (je nach Aktion) xApiKey, openRouterApiKey | Ja: `sm_yt_monitor`, `sm_transcript_notifier` |
| **AgentComm** | Nachrichten an andere Agenten senden | (keine) | Nein |
| **AgentSpawn** | Andere Agenten starten und beauftragen | (keine) | Nein |
### Zuweisungsregeln
1. **Minimalprinzip:** Nur Tools zuweisen, die der Agent tatsächlich braucht
2. **Tool = Berechtigung:** Ein Tool in `tools` aufzunehmen gewährt sofort Zugriff (PermissionGate prüft nur Existenz im Dictionary)
3. **Config = Sicherheitsgrenze:** `allowedDomains`, `allowedTables`, `allowedRecipients` etc. sind die echten Einschränkungen
4. **AgentComm/AgentSpawn:** Nur dem Koordinator-Agenten zuweisen, nicht jedem
5. **Tool Jobs prüfen:** Ein ToolJob funktioniert nur wenn der Agent das Tool auch zugewiesen hat — die Laufzeitprüfung deaktiviert den Job sonst automatisch
### Typische Tool-Kombinationen
| Agenten-Rolle | Tools |
|--------------|-------|
| Koordinator | FileRW, AgentComm, AgentSpawn |
| Recherche-Agent | FileRW, WebFetch, WebMonitor |
| Kommunikations-Agent | FileRW, Mail, Telegram |
| Datenbank-Agent | FileRW, Database |
| Social-Media-Agent | FileRW, SocialMediaManager, WebFetch |
| Finanz-Agent | FileRW, DirectAPI, Database |
---
## 6. Kritische Analyse: Ist die Idee umsetzbar?
Vor dem Aufbau einer Instanz systematisch prüfen:
### Checkliste: Machbarkeit
| Prüfpunkt | Frage | Risiko wenn nein |
|-----------|-------|-----------------|
| **Tool-Abdeckung** | Gibt es für jeden externen Zugang ein passendes Tool? | Hoch — ohne Tool kein Zugriff |
| **API-Verfügbarkeit** | Haben alle externen APIs die nötigen Endpunkte? | Hoch — Tool ist nutzlos ohne funktionierende API |
| **Datenformat** | Kann das Tool die Daten in einem Format liefern, das das LLM verarbeiten kann? | Mittel — große/binäre Daten überfordern den Context |
| **Autonomie-Level** | Kann die Aufgabe ohne menschliche Zwischenschritte laufen? | Mittel — wenn nicht, braucht es manuelle Trigger statt Cron |
| **Budget** | Reicht das Token-Budget für die geplante Frequenz? | Hoch — unterschätzter Verbrauch ist der häufigste Fehler |
| **Koordination** | Brauchen Agenten wirklich Echtzeit-Kommunikation oder reicht SharedWorkspace? | Mittel — AgentComm kostet Token auf beiden Seiten |
### Häufige Fallstricke und Lösungen
#### Fallstrick 1: Token-Explosion durch Polling
**Problem:** Agent wird per Cron alle 2 Minuten geweckt → 720 LLM-Runs/Tag, auch wenn nichts passiert ist.
**Lösung:** Tool Jobs (`IToolJobProvider`) statt Agent-Wakeup verwenden. Das Tool prüft selbst (kein LLM) und weckt den Agenten nur bei Bedarf. Kostet ~0 Token pro Leer-Check.
```json
"scheduler": null,
"toolJobs": [
{
"toolName": "Telegram",
"jobTypeId": "telegram_poll",
"cron": "*/1 * * * *",
"enabled": true
}
]
```
#### Fallstrick 2: Context-Overflow bei großen Daten
**Problem:** WebFetch liefert 50 KB HTML, Database-Query liefert 200 Zeilen → Context voll, Compaction verliert wichtige Infos.
**Lösung:**
- `maxResponseKb` in WebFetch begrenzen (Standard: 512 KB — oft zu viel)
- Database-Queries mit LIMIT versehen (in SystemPrompt anweisen)
- LoopGuard `maxContextTokens` angemessen setzen
- Agent in der Identity anweisen, Ergebnisse sofort zu verarbeiten und zusammenzufassen
#### Fallstrick 3: Agenten reden aneinander vorbei
**Problem:** AgentComm-Nachrichten ohne klare Struktur → Missverständnisse, Endlosschleifen.
**Lösung:**
- In Soul.md ein festes Nachrichtenformat definieren (JSON mit `type`, `content`, `expectedAction`)
- SharedWorkspace für asynchronen Datenaustausch bevorzugen (Dateien statt Nachrichten)
- Koordinator-Agent als einzigen mit AgentSpawn/AgentComm ausstatten
#### Fallstrick 4: Scheduler ohne sichtbares Ergebnis
**Problem:** Agent wird per Cron geweckt, arbeitet im Hintergrund, aber niemand sieht ob es funktioniert.
**Lösung:**
- Agent soll Ergebnisse in den SharedWorkspace schreiben (prüfbar per FileRW)
- Wichtige Ergebnisse per Mail oder Telegram melden lassen
- TokenUsage.json regelmäßig prüfen (Kosten vs. erwarteter Output)
#### Fallstrick 5: Tool-Config Fehler erst zur Laufzeit sichtbar
**Problem:** Falscher `botToken`, fehlender `connectionString` → Agent startet, Tool schlägt beim ersten Aufruf fehl.
**Lösung:**
- Vor dem Scheduler-Start einen manuellen Test-Run durchführen
- Agent manuell starten mit einer Test-Aufgabe: "Sende eine Test-Nachricht über Telegram"
- Logs unter `Agent-{Name}/Logs/` prüfen
#### Fallstrick 6: Kosten unterschätzt bei Multi-Agent-Setup
**Problem:** 3 Agenten × Cron alle 10 Min × Claude Sonnet = ~$130/Tag.
**Lösung:**
- Billige Modelle für Routinearbeit (Gemini Flash, Haiku)
- Teure Modelle nur für Koordinator oder komplexe Analyse
- LoopGuard `maxTokens` aggressiv begrenzen für Routine-Agenten
- Tool Jobs statt Agent-Wakeup wo möglich
#### Fallstrick 7: YouTube-Transkription braucht externes Tool
**Problem:** `SocialMediaManager` mit `sm_yt_monitor` Job braucht `yt-dlp` als externes CLI-Tool auf dem System installiert.
**Lösung:**
- Vor Instanz-Setup prüfen ob `yt-dlp` installiert und im PATH ist
- Ohne `yt-dlp` funktionieren `x_search` und `reddit_search` trotzdem
---
## 7. Beispiel-Instanz: "TelegramBot"
Einfache Instanz mit einem Agenten, der über Telegram kommuniziert.
### Planung
- **Aufgabe:** Auf Telegram-Nachrichten antworten, einfache Fragen beantworten, Dateien im Workspace verwalten
- **Agenten:** 1 (kein Koordinationsbedarf)
- **Budget:** ~$0.50/Tag
- **Modell:** `google/gemini-2.5-flash` (günstig, ausreichend für Chat)
### Verzeichnisstruktur
```
Instance-TelegramBot/
├── InstanceSettings.json
├── TokenUsage.json
└── Agents/
├── AgentList.json
├── SharedWorkspace/
└── Agent-Assistent/
├── AgentSettings.json
├── Identity.md
├── Soul.md
├── Logs/
└── Workspace/
```
### AgentList.json
```json
{
"agents": [
{
"name": "Assistent",
"description": "Beantwortet Telegram-Nachrichten und verwaltet Notizen",
"folderName": "Agent-Assistent"
}
]
}
```
### AgentSettings.json
```json
{
"agentId": "assistent",
"displayName": "Assistent",
"model": "google/gemini-2.5-flash",
"systemPrompt": "",
"tools": {
"Telegram": {
"botToken": "123456:ABC-DEF...",
"defaultChatId": "987654321",
"allowedChatIds": ["987654321"]
},
"FileRW": {
"sharedAccessLevel": "Denied",
"personalAllowedExtensions": [".txt", ".json", ".md"]
}
},
"scheduler": null,
"toolJobs": [
{
"jobId": "tgpoll01",
"toolName": "Telegram",
"jobTypeId": "telegram_poll",
"cron": "*/1 * * * *",
"enabled": true,
"runOnStart": true
}
],
"loopGuard": {
"maxSteps": 10,
"maxTokens": 30000,
"timeoutSeconds": 120,
"maxContextTokens": 100000,
"compactionThreshold": 0.8
}
}
```
### Kostenabschätzung
- Telegram-Polling: 1440 Ticks/Tag, ~0 Token (Tool Job, kein LLM)
- Agent-Wakeups: geschätzt 20 Nachrichten/Tag × ~$0.00135/Run = **~$0.03/Tag**
- Deutlich unter dem $0.50 Budget
---
## 8. Beispiel-Instanz: "Recherche-Team"
Multi-Agent-Setup für automatisierte Web-Recherche mit Berichterstattung.
### Planung
- **Aufgabe:** Täglich Webseiten/RSS-Feeds prüfen, relevante Infos sammeln, Bericht per E-Mail senden
- **Agenten:** 3 (Recherche, Analyse, Bericht)
- **Budget:** ~$5/Tag
### Agenten-Design
| Agent | Modell | Tools | Scheduler | Zweck |
|-------|--------|-------|-----------|-------|
| Crawler | `google/gemini-2.5-flash` | FileRW, WebFetch, WebMonitor | Cron `0 7 * * *` | Daten sammeln, in SharedWorkspace ablegen |
| Analyst | `anthropic/claude-sonnet-4-5` | FileRW | Cron `0 8 * * *` | Daten analysieren, Zusammenfassung erstellen |
| Reporter | `google/gemini-2.5-flash` | FileRW, Mail | Cron `0 9 * * *` | Zusammenfassung als E-Mail versenden |
### Warum dieses Setup?
- **Crawler** braucht kein teures Modell — holt nur Daten ab und speichert sie
- **Analyst** bekommt Sonnet weil Analyse-Qualität hier den Unterschied macht
- **Reporter** braucht kein teures Modell — formatiert nur und sendet
- **Zeitversatz** (7:00 → 8:00 → 9:00) statt AgentComm — günstiger und zuverlässiger
- **SharedWorkspace** statt Echtzeit-Kommunikation — keine doppelten Token-Kosten
### Kostenabschätzung
- Crawler: 1 Run/Tag × ~$0.002 = $0.002
- Analyst: 1 Run/Tag × ~$0.03 = $0.03
- Reporter: 1 Run/Tag × ~$0.002 = $0.002
- **~$0.034/Tag** — weit unter Budget
---
## 9. Checkliste: Neue Instanz aufsetzen
- [ ] Aufgabe und Agenten-Aufteilung definiert
- [ ] Kostenabschätzung durchgeführt (Modell × Frequenz × Agenten)
- [ ] Prüfung: Alle nötigen Tools vorhanden?
- [ ] Prüfung: Alle externen APIs/Zugangsdaten verfügbar?
- [ ] Prüfung: Externe Abhängigkeiten installiert? (z.B. yt-dlp)
- [ ] Instanz-Ordner erstellt (manuell oder über UI)
- [ ] `InstanceSettings.json` mit API-Key konfiguriert
- [ ] `AgentList.json` mit allen Agenten erstellt
- [ ] Pro Agent: `AgentSettings.json` mit Modell, Tools, LoopGuard
- [ ] Pro Agent: `Identity.md` (Rolle, Expertise, Kontext)
- [ ] Pro Agent: `Soul.md` (Persönlichkeit, Methodik, Ausgabeformat)
- [ ] Pro Agent: Tool-Configs mit echten Zugangsdaten befüllt
- [ ] Tool Jobs konfiguriert (statt Agent-Wakeup wo sinnvoll)
- [ ] Manueller Test-Run pro Agent durchgeführt
- [ ] Logs geprüft — keine Tool-Fehler?
- [ ] Scheduler aktiviert
- [ ] Nach 24h: TokenUsage.json prüfen, Kosten validieren
+465
View File
@@ -0,0 +1,465 @@
# ClawdDotNet Tool-Entwicklungsanleitung
## Übersicht
Tools sind eigenständige Plugins, die Agenten Zugriff auf externe Systeme geben (Datenbanken, Dateisysteme, E-Mail, APIs etc.). Jedes Tool ist ein separates .NET-Projekt, das ausschließlich `ClawdDotNet.Core` referenziert.
**Kernregeln:**
- Tools sind **niemals voneinander abhängig** (Tool A darf Tool B nicht kennen)
- Der Core kompiliert und läuft **ohne jedes Tool**
- Jedes Tool ist **pro Agent konfiguriert** (via `AgentConfig.Tools`)
- Tools werden zur Laufzeit über die `ToolRegistry` registriert
---
## Projekt-Struktur
```
ClawdDotNet.sln
├── src/
│ ├── ClawdDotNet.Core/ ← Klassenbibliothek (keine Tool-Verweise!)
│ ├── ClawdDotNet.Tools.MeinTool/ ← Dein Tool-Plugin
│ │ ├── ClawdDotNet.Tools.MeinTool.csproj
│ │ └── MeinToolTool.cs
│ └── ClawdDotNet.Host/ ← WinForms-App (verweist auf Core + alle Tools)
```
### Neues Tool-Projekt anlegen
```xml
<!-- ClawdDotNet.Tools.MeinTool.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Tools.MeinTool</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- NUR Core referenzieren, KEINE anderen Tools -->
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Tool-spezifische NuGet-Packages hier -->
</ItemGroup>
</Project>
```
---
## IAgentTool implementieren
Jedes Tool implementiert das Interface `ClawdDotNet.Core.Tools.IAgentTool`:
```csharp
using System.Text.Json;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Tools.MeinTool;
public sealed class MeinToolTool : IAgentTool
{
// 1. Eindeutiger Name wird vom LLM in tool_calls verwendet
public string Name => "MeinTool";
// 2. Beschreibung für das LLM (geht in den System-Prompt)
public string Description => "Beschreibung, was dieses Tool kann...";
// 3. JSON Schema des Input-Objekts (OpenAI Function Calling Format)
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["read", "write"],
"description": "Die auszuführende Aktion"
},
"data": {
"type": "string",
"description": "Eingabedaten"
}
},
"required": ["action"]
}
""").RootElement.Clone();
// 4. Ausführung
public async Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
CancellationToken ct)
{
// Action aus dem Input lesen
var action = input.GetProperty("action").GetString()
?? throw new ArgumentException("'action' is required");
// Konfiguration aus dem AgentToolContext lesen
// NIEMALS hardcodierte Werte, immer aus context.ToolConfig!
var meineSetting = context.ToolConfig.TryGetValue("meineSetting", out var val)
? val?.ToString() ?? ""
: "";
// Logger verwenden
context.Logger.LogInformation("MeinTool executing action: {Action}", action);
return action switch
{
"read" => await HandleReadAsync(input, context, ct),
"write" => await HandleWriteAsync(input, context, ct),
_ => ToolResult.Fail($"Unknown action: {action}")
};
}
private async Task<ToolResult> HandleReadAsync(
JsonElement input, AgentToolContext context, CancellationToken ct)
{
// Implementierung...
await Task.CompletedTask; // Platzhalter
return ToolResult.Ok("Ergebnis als JSON oder Text");
}
private async Task<ToolResult> HandleWriteAsync(
JsonElement input, AgentToolContext context, CancellationToken ct)
{
// Implementierung...
await Task.CompletedTask; // Platzhalter
return ToolResult.Ok("Erfolgreich geschrieben");
}
}
```
---
## Konfiguration pro Agent
Die Tool-Konfiguration ist **pro Agent** definiert, nicht global. Das bedeutet: Agent A kann auf Datenbank X zugreifen, Agent B auf Datenbank Y.
### In der Config-Datei (z.B. `config.json`):
```json
{
"agents": [
{
"agentId": "analyst",
"tools": {
"MeinTool": {
"meineSetting": "wert-fuer-agent-analyst",
"andereOption": true
}
}
},
{
"agentId": "developer",
"tools": {
"MeinTool": {
"meineSetting": "wert-fuer-agent-developer",
"andereOption": false
}
}
}
]
}
```
### Konfiguration im Tool auslesen:
```csharp
// Im ExecuteAsync:
var setting = context.ToolConfig["meineSetting"]?.ToString();
var option = context.ToolConfig.TryGetValue("andereOption", out var v)
&& v is JsonElement je
&& je.GetBoolean();
```
**Wichtig:** `context.ToolConfig` enthält nur die Config für dieses Tool und diesen Agent. Du bekommst nie die Config eines anderen Agents oder eines anderen Tools.
---
## Tool im Host registrieren
Im `Host/Program.cs` (oder einer Startup-Klasse) wird das Tool registriert:
```csharp
var registry = new ToolRegistry();
// Jedes Tool einzeln registrieren
registry.Register(new MeinToolTool());
registry.Register(new DatabaseTool());
registry.Register(new FileRWTool());
// ...
```
Der `AgentEngine` nutzt dann die `ToolRegistry`, um für jeden Agent nur die erlaubten Tools bereitzustellen.
---
## Geplante Tools (Referenz)
### Database (`ClawdDotNet.Tools.Database`)
| Eigenschaft | Wert |
|---|---|
| Name | `Database` |
| NuGet | `MySqlConnector`, `MongoDB.Driver` |
| Aktionen | `query`, `insert`, `upsert` |
**Config-Felder:**
- `connectionString` Datenbankverbindung (pro Agent!)
- `type` `"mysql"` oder `"mongodb"`
- `allowedTables` Liste erlaubter Tabellen (Whitelist)
**Sicherheit:**
- Parameterized Queries gegen SQL-Injection
- Nur Tabellen aus `allowedTables` erlaubt
- Connection String kommt IMMER aus `context.ToolConfig`
---
### FileRW (`ClawdDotNet.Tools.FileRW`)
| Eigenschaft | Wert |
|---|---|
| Name | `FileRW` |
| NuGet | (keine) |
| Aktionen | `read`, `write`, `append`, `list`, `delete` |
**Config-Felder:**
- `rootPath` Basisverzeichnis (Agent ist darin eingesperrt)
- `allowWrite` `true`/`false`
- `allowedExtensions` z.B. `[".txt", ".json", ".html"]`
**Sicherheit (Pflicht):**
- Path-Traversal-Check: `Path.GetFullPath(requested).StartsWith(Path.GetFullPath(rootPath))`
- Nur erlaubte Dateiendungen
- Schreibzugriff nur wenn `allowWrite = true`
---
### Mail (`ClawdDotNet.Tools.Mail`)
| Eigenschaft | Wert |
|---|---|
| Name | `Mail` |
| NuGet | `MailKit` |
| Aktionen | `send`, `read_inbox`, `read_message`, `mark_read` |
**Config-Felder:**
- `imapHost`, `imapPort` IMAP-Server
- `smtpHost`, `smtpPort` SMTP-Server
- `username`, `password` Zugangsdaten
- `allowedRecipients` Whitelist erlaubter Empfänger
**Sicherheit:**
- Empfänger müssen in `allowedRecipients` stehen
---
## AgentToolContext Referenz
```csharp
public sealed record AgentToolContext(
string AgentId, // ID des ausführenden Agents
string InstanceId, // ID der laufenden ClawdDotNet-Instanz
IReadOnlyDictionary<string, object?> ToolConfig, // Tool-Config für DIESEN Agent
ILogger Logger, // Logger (schreibt in Tool_{ToolName}.log)
CancellationToken CancellationToken
);
```
---
## ToolResult Referenz
```csharp
public sealed record ToolResult(
bool Success,
string Content, // Geht zurück ans LLM
string? ErrorMessage = null
);
// Hilfsmethoden:
ToolResult.Ok("Ergebnis als JSON oder Text");
ToolResult.Fail("Fehlerbeschreibung");
```
---
## Background Jobs (optional)
Manche Tools müssen regelmäßig im Hintergrund arbeiten, ohne dabei jedes Mal den Agenten (und damit das LLM) aufzuwecken. Beispiel: Ein Telegram-Tool prüft alle 30 Sekunden auf neue Nachrichten, weckt den Agenten aber **nur** wenn tatsächlich eine neue Nachricht vorliegt.
Dafür gibt es das optionale Interface `IToolJobProvider`. Ein Tool kann es **zusätzlich** zu `IAgentTool` implementieren — es ist kein Ersatz.
### IToolJobProvider implementieren
```csharp
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Core.State;
using Microsoft.Extensions.Logging;
public sealed class MeinToolTool : IAgentTool, IToolJobProvider
{
// ... IAgentTool-Implementierung wie oben ...
// 1. Verfügbare Job-Typen deklarieren
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() =>
[
new("meintool_check", "MeinTool Check", "Prüft regelmäßig auf Änderungen")
];
// 2. Job-Tick ausführen (kein LLM, nur Tool-Code!)
public async Task<ToolJobResult> ExecuteJobAsync(
string jobTypeId,
IReadOnlyDictionary<string, object?> toolConfig,
IStateStore stateStore,
ILogger logger,
CancellationToken ct)
{
if (jobTypeId != "meintool_check")
return ToolJobResult.NoAction($"Unbekannter Job: {jobTypeId}");
// Config auslesen (gleiche Felder wie in ExecuteAsync)
var apiKey = toolConfig.TryGetValue("apiKey", out var v)
? v?.ToString() ?? "" : "";
// Zustand über IStateStore persistieren (überlebt Neustarts)
var lastOffset = await stateStore.GetAsync("meintool_last_offset") ?? "0";
// Prüfung durchführen...
var newItems = await CheckForUpdatesAsync(apiKey, lastOffset, ct);
if (newItems.Count == 0)
return ToolJobResult.NoAction("Keine neuen Einträge");
// Offset speichern
await stateStore.SetAsync("meintool_last_offset", newItems.Last().Id);
// Agent aufwecken mit Zusammenfassung
return ToolJobResult.Wake(
$"{newItems.Count} neue Einträge gefunden:\n" +
string.Join("\n", newItems.Select(i => $"- {i.Title}")),
logSummary: $"{newItems.Count} neue Einträge"
);
}
}
```
### ToolJobResult
Das Tool gibt deklarativ zurück, ob der Agent geweckt werden soll:
| Methode | Effekt |
|---------|--------|
| `ToolJobResult.NoAction(logSummary?)` | Nichts tun, optional Log-Eintrag |
| `ToolJobResult.Wake(wakeMessage, logSummary?)` | Agent **im bestehenden Chat** aufwecken (Default) |
| `ToolJobResult.WakeStateless(wakeMessage, logSummary?)` | Agent in **isolierter Session** aufwecken (kein Chatverlauf) |
**`Wake` vs `WakeStateless`:**
- `Wake` (Default) nutzt `ChatAsync` — die Nachricht erscheint im Chat-Tab, der Agent behält den gesamten Konversationsverlauf. Ideal für alles was Teil einer laufenden Interaktion ist (Telegram-Nachrichten, fertige Transkripte, E-Mail-Antworten).
- `WakeStateless` nutzt `RunAsync` — komplett isolierte Ausführung ohne Kontext. Nur für einmalige, kontextfreie Aufgaben verwenden.
**Wichtig:** Das Tool hat keinen direkten Zugriff auf den `AgentEngine`. Der `ToolJobScheduler` wertet das Ergebnis aus und weckt den Agenten bei Bedarf.
### IStateStore für Zustandstracking
`IStateStore` ist ein einfacher Key-Value-Store (SQLite-basiert), der zwischen Job-Ticks und Neustarts persistiert:
```csharp
// Wert lesen (null wenn nicht vorhanden)
string? value = await stateStore.GetAsync("mein_key");
// Wert schreiben
await stateStore.SetAsync("mein_key", "neuer_wert");
```
Verwende aussagekräftige Keys, z.B. `meintool_poll_offset` oder `meintool_last_check`.
### JSON-Konfiguration
Tool Jobs werden pro Agent in der `config.json` konfiguriert, parallel zum bestehenden `scheduler`:
```json
{
"agentId": "support-bot",
"tools": {
"MeinTool": { "apiKey": "..." }
},
"scheduler": [
{ "cron": "0 8 * * 1-5", "taskMessage": "Morgenbericht erstellen" }
],
"toolJobs": [
{
"jobId": "abc12345",
"toolName": "MeinTool",
"jobTypeId": "meintool_check",
"cron": "*/5 * * * *",
"enabled": true,
"runOnStart": false
}
]
}
```
| Feld | Beschreibung |
|------|-------------|
| `jobId` | Eindeutige ID (wird automatisch generiert) |
| `toolName` | Name des Tools (muss `IToolJobProvider` implementieren) |
| `jobTypeId` | Einer der von `GetJobDefinitions()` deklarierten IDs |
| `cron` | Cron-Ausdruck für die Ausführungsintervalle |
| `enabled` | Job aktiv/inaktiv |
| `runOnStart` | Beim Programmstart sofort einmal ausführen |
### Unterschied: Agent Wakeup vs Tool Job
| | Agent Wakeup (`scheduler`) | Tool Job (`toolJobs`) |
|---|---|---|
| **Ausführung** | Voller LLM-Run | Nur Tool-Code (kein LLM) |
| **Kosten** | Token pro Tick | Kostenlos pro Tick |
| **Agent-Aufruf** | Immer | Nur bei `ShouldWakeAgent` |
| **Anwendungsfall** | Regelmäßige Aufgaben | Polling, Monitoring, Checks |
---
## Checkliste für neue Tools
- [ ] Eigenes Projekt `ClawdDotNet.Tools.{Name}` anlegen
- [ ] Nur `ClawdDotNet.Core` referenzieren
- [ ] `IAgentTool` implementieren
- [ ] `InputSchema` als gültiges JSON Schema definieren
- [ ] Alle Konfiguration aus `context.ToolConfig` lesen
- [ ] Alle `await`-Aufrufe mit `CancellationToken` versehen
- [ ] `context.Logger` für Logging verwenden (kein `Console.WriteLine`)
- [ ] Sicherheitsprüfungen implementieren (je nach Tool-Typ)
- [ ] Im Host registrieren: `registry.Register(new MeinToolTool());`
- [ ] In der Solution-Datei (.slnx) einbinden
- [ ] NuGet-Packages in `NuGet.Config` PackageSourceMapping ergänzen
- [ ] *Optional:* `IToolJobProvider` implementieren (wenn Hintergrund-Polling nötig)
- [ ] *Optional:* `GetJobDefinitions()` mit eindeutigen `JobTypeId`s definieren
- [ ] *Optional:* `ExecuteJobAsync()` implementieren, `IStateStore` für Zustandstracking nutzen
---
## Logging
Das Logging-System trennt automatisch nach Modul. Wenn dein Tool den Logger aus dem `AgentToolContext` verwendet, landen die Logs automatisch in:
```
Logs/
├── 2026-05-12/
│ ├── Core.log ← Engine, Scheduler, Config
│ ├── Host.log ← WinForms UI
│ ├── Tool_Database.log ← Database-Tool
│ ├── Tool_FileRW.log ← FileRW-Tool
│ ├── Tool_MeinTool.log ← Dein Tool!
│ └── ...
```
Die Zuordnung geschieht über den Namespace:
- `ClawdDotNet.Core.*``Core.log`
- `ClawdDotNet.Tools.{Name}.*``Tool_{Name}.log`
- `ClawdDotNet.Host.*``Host.log`
Log-Level: `Debug`, `Info`, `Warn`, `Error`
+246
View File
@@ -0,0 +1,246 @@
namespace ClawdDotNet
{
partial class frm_AddJob
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
lblJobType = new Label();
cbJobType = new ComboBox();
lblAgent = new Label();
cbAgent = new ComboBox();
lblTool = new Label();
cbTool = new ComboBox();
lblJobDef = new Label();
cbJobDef = new ComboBox();
lblCron = new Label();
txtCron = new TextBox();
lblPreview = new Label();
lblTask = new Label();
txtTask = new TextBox();
chkRunOnStart = new CheckBox();
lblHelp = new Label();
btnOk = new Button();
btnCancel = new Button();
SuspendLayout();
//
// lblJobType
//
lblJobType.AutoSize = true;
lblJobType.Location = new Point(12, 9);
lblJobType.Name = "lblJobType";
lblJobType.Size = new Size(64, 25);
lblJobType.TabIndex = 0;
lblJobType.Text = "Job-Typ:";
//
// cbJobType
//
cbJobType.DropDownStyle = ComboBoxStyle.DropDownList;
cbJobType.Items.AddRange(new object[] { "Agent Wakeup", "Tool Job" });
cbJobType.Location = new Point(130, 6);
cbJobType.Name = "cbJobType";
cbJobType.Size = new Size(326, 33);
cbJobType.TabIndex = 1;
cbJobType.SelectedIndexChanged += OnJobTypeChanged;
//
// lblAgent
//
lblAgent.AutoSize = true;
lblAgent.Location = new Point(12, 48);
lblAgent.Name = "lblAgent";
lblAgent.Size = new Size(64, 25);
lblAgent.TabIndex = 2;
lblAgent.Text = "Agent:";
//
// cbAgent
//
cbAgent.DropDownStyle = ComboBoxStyle.DropDownList;
cbAgent.Location = new Point(130, 45);
cbAgent.Name = "cbAgent";
cbAgent.Size = new Size(326, 33);
cbAgent.TabIndex = 3;
//
// lblTool
//
lblTool.AutoSize = true;
lblTool.Location = new Point(12, 87);
lblTool.Name = "lblTool";
lblTool.Size = new Size(45, 25);
lblTool.TabIndex = 4;
lblTool.Text = "Tool:";
//
// cbTool
//
cbTool.DropDownStyle = ComboBoxStyle.DropDownList;
cbTool.Location = new Point(130, 84);
cbTool.Name = "cbTool";
cbTool.Size = new Size(326, 33);
cbTool.TabIndex = 5;
cbTool.SelectedIndexChanged += OnToolChanged;
//
// lblJobDef
//
lblJobDef.AutoSize = true;
lblJobDef.Location = new Point(12, 126);
lblJobDef.Name = "lblJobDef";
lblJobDef.Size = new Size(64, 25);
lblJobDef.TabIndex = 6;
lblJobDef.Text = "Job:";
//
// cbJobDef
//
cbJobDef.DropDownStyle = ComboBoxStyle.DropDownList;
cbJobDef.Location = new Point(130, 123);
cbJobDef.Name = "cbJobDef";
cbJobDef.Size = new Size(326, 33);
cbJobDef.TabIndex = 7;
//
// lblCron
//
lblCron.AutoSize = true;
lblCron.Location = new Point(12, 168);
lblCron.Name = "lblCron";
lblCron.Size = new Size(79, 25);
lblCron.TabIndex = 8;
lblCron.Text = "Zeitplan:";
//
// txtCron
//
txtCron.Location = new Point(130, 165);
txtCron.Name = "txtCron";
txtCron.PlaceholderText = "z.B. */2 * * * * (alle 2 Min)";
txtCron.Size = new Size(326, 31);
txtCron.TabIndex = 9;
txtCron.TextChanged += OnCronChanged;
//
// lblPreview
//
lblPreview.Font = new Font("Segoe UI", 8F);
lblPreview.ForeColor = Color.Gray;
lblPreview.Location = new Point(130, 199);
lblPreview.Name = "lblPreview";
lblPreview.Size = new Size(326, 20);
lblPreview.TabIndex = 10;
//
// lblTask
//
lblTask.AutoSize = true;
lblTask.Location = new Point(12, 226);
lblTask.Name = "lblTask";
lblTask.Size = new Size(84, 25);
lblTask.TabIndex = 11;
lblTask.Text = "Aufgabe:";
//
// txtTask
//
txtTask.Location = new Point(130, 223);
txtTask.Multiline = true;
txtTask.Name = "txtTask";
txtTask.ScrollBars = ScrollBars.Vertical;
txtTask.Size = new Size(326, 80);
txtTask.TabIndex = 12;
txtTask.Text = "Führe deine zugewiesenen Aufgaben aus.";
//
// chkRunOnStart
//
chkRunOnStart.AutoSize = true;
chkRunOnStart.Location = new Point(12, 312);
chkRunOnStart.Name = "chkRunOnStart";
chkRunOnStart.Size = new Size(395, 29);
chkRunOnStart.TabIndex = 13;
chkRunOnStart.Text = "Beim Programmstart sofort einmal ausführen";
//
// lblHelp
//
lblHelp.Font = new Font("Segoe UI", 8F);
lblHelp.ForeColor = Color.DimGray;
lblHelp.Location = new Point(12, 348);
lblHelp.Name = "lblHelp";
lblHelp.Size = new Size(444, 60);
lblHelp.TabIndex = 14;
lblHelp.Text = "Cron-Format: Min Std Tag Mon Wochentag\r\nBeispiele: */5 * * * * = alle 5 Min\r\n 0 8 * * 1-5 = Mo-Fr um 08:00\r\n 0 */2 * * * = alle 2 Stunden";
//
// btnOk
//
btnOk.DialogResult = DialogResult.OK;
btnOk.Location = new Point(240, 418);
btnOk.Name = "btnOk";
btnOk.Size = new Size(106, 38);
btnOk.TabIndex = 15;
btnOk.Text = "Hinzufügen";
btnOk.Click += OnOkClick;
//
// btnCancel
//
btnCancel.DialogResult = DialogResult.Cancel;
btnCancel.Location = new Point(356, 418);
btnCancel.Name = "btnCancel";
btnCancel.Size = new Size(100, 38);
btnCancel.TabIndex = 16;
btnCancel.Text = "Abbrechen";
//
// frm_AddJob
//
AcceptButton = btnOk;
CancelButton = btnCancel;
ClientSize = new Size(470, 470);
Controls.Add(lblJobType);
Controls.Add(cbJobType);
Controls.Add(lblAgent);
Controls.Add(cbAgent);
Controls.Add(lblTool);
Controls.Add(cbTool);
Controls.Add(lblJobDef);
Controls.Add(cbJobDef);
Controls.Add(lblCron);
Controls.Add(txtCron);
Controls.Add(lblPreview);
Controls.Add(lblTask);
Controls.Add(txtTask);
Controls.Add(chkRunOnStart);
Controls.Add(lblHelp);
Controls.Add(btnOk);
Controls.Add(btnCancel);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
Name = "frm_AddJob";
StartPosition = FormStartPosition.CenterParent;
Text = "Job hinzufügen";
ResumeLayout(false);
PerformLayout();
}
#endregion
private Label lblJobType;
private ComboBox cbJobType;
private Label lblAgent;
private ComboBox cbAgent;
private Label lblTool;
private ComboBox cbTool;
private Label lblJobDef;
private ComboBox cbJobDef;
private Label lblCron;
private TextBox txtCron;
private Label lblPreview;
private Label lblTask;
private TextBox txtTask;
private CheckBox chkRunOnStart;
private Label lblHelp;
private Button btnOk;
private Button btnCancel;
}
}
+258
View File
@@ -0,0 +1,258 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Scheduling;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet;
public sealed partial class frm_AddJob : Form
{
private readonly IReadOnlyList<AgentConfig> _agents;
private readonly IReadOnlyList<(IAgentTool Tool, IToolJobProvider Provider)> _jobProviders;
// ─── Output Properties ───
public bool IsToolJob => cbJobType.SelectedIndex == 1;
public string SelectedAgentId => (cbAgent.SelectedItem as AgentComboItem)?.AgentId ?? "";
public string CronExpression => txtCron.Text.Trim();
public string TaskMessage => txtTask.Text.Trim();
public bool RunOnStart => chkRunOnStart.Checked;
public string SelectedToolName => (cbTool.SelectedItem as ToolComboItem)?.ToolName ?? "";
public string SelectedJobTypeId => (cbJobDef.SelectedItem as JobDefComboItem)?.JobTypeId ?? "";
/// <summary>Konstruktor für "Hinzufügen"</summary>
public frm_AddJob(IReadOnlyList<AgentConfig> agents, ToolRegistry toolRegistry)
{
InitializeComponent();
_agents = agents;
_jobProviders = toolRegistry.GetJobProviders();
foreach (var agent in agents)
cbAgent.Items.Add(new AgentComboItem(agent.AgentId, agent.DisplayName));
if (cbAgent.Items.Count > 0) cbAgent.SelectedIndex = 0;
foreach (var (tool, _) in _jobProviders)
cbTool.Items.Add(new ToolComboItem(tool.Name, tool.Description));
if (cbTool.Items.Count > 0) cbTool.SelectedIndex = 0;
cbJobType.SelectedIndex = 0;
}
/// <summary>Konstruktor für "Bearbeiten" — Agent, Tool und Job-Typ sind gesperrt</summary>
public frm_AddJob(IReadOnlyList<AgentConfig> agents, ToolRegistry toolRegistry,
string agentId, string cron, string taskMessage, bool runOnStart,
bool isToolJob, string? toolName = null, string? jobTypeId = null)
: this(agents, toolRegistry)
{
Text = "Job bearbeiten";
btnOk.Text = "Speichern";
// Job-Typ setzen und sperren
cbJobType.SelectedIndex = isToolJob ? 1 : 0;
cbJobType.Enabled = false;
// Agent vorauswählen und sperren
for (int i = 0; i < cbAgent.Items.Count; i++)
{
if (((AgentComboItem)cbAgent.Items[i]!).AgentId == agentId)
{
cbAgent.SelectedIndex = i;
break;
}
}
cbAgent.Enabled = false;
// Bearbeitbare Felder befüllen
txtCron.Text = cron;
chkRunOnStart.Checked = runOnStart;
if (isToolJob && toolName is not null)
{
// Tool vorauswählen und sperren
for (int i = 0; i < cbTool.Items.Count; i++)
{
if (((ToolComboItem)cbTool.Items[i]!).ToolName == toolName)
{
cbTool.SelectedIndex = i;
break;
}
}
cbTool.Enabled = false;
// Job-Definition vorauswählen und sperren
if (jobTypeId is not null)
{
for (int i = 0; i < cbJobDef.Items.Count; i++)
{
if (((JobDefComboItem)cbJobDef.Items[i]!).JobTypeId == jobTypeId)
{
cbJobDef.SelectedIndex = i;
break;
}
}
}
cbJobDef.Enabled = false;
}
else
{
txtTask.Text = taskMessage;
}
}
private void OnJobTypeChanged(object? sender, EventArgs e)
{
var isToolJob = cbJobType.SelectedIndex == 1;
lblTool.Visible = isToolJob;
cbTool.Visible = isToolJob;
lblJobDef.Visible = isToolJob;
cbJobDef.Visible = isToolJob;
lblTask.Visible = !isToolJob;
txtTask.Visible = !isToolJob;
RefreshAgentList();
}
private void OnToolChanged(object? sender, EventArgs e)
{
cbJobDef.Items.Clear();
if (cbTool.SelectedItem is not ToolComboItem toolItem)
return;
var provider = _jobProviders.FirstOrDefault(p => ((IAgentTool)p.Provider).Name == toolItem.ToolName);
if (provider.Provider is null)
return;
foreach (var def in provider.Provider.GetJobDefinitions())
cbJobDef.Items.Add(new JobDefComboItem(def.JobTypeId, def.DisplayName, def.Description));
if (cbJobDef.Items.Count > 0) cbJobDef.SelectedIndex = 0;
RefreshAgentList();
}
private void RefreshAgentList()
{
var previousAgentId = (cbAgent.SelectedItem as AgentComboItem)?.AgentId;
cbAgent.Items.Clear();
var toolName = IsToolJob && cbTool.SelectedItem is ToolComboItem toolItem
? toolItem.ToolName : null;
foreach (var agent in _agents)
{
if (toolName is not null && !agent.Tools.ContainsKey(toolName))
continue;
cbAgent.Items.Add(new AgentComboItem(agent.AgentId, agent.DisplayName));
}
if (previousAgentId is not null)
{
for (int i = 0; i < cbAgent.Items.Count; i++)
{
if (((AgentComboItem)cbAgent.Items[i]!).AgentId == previousAgentId)
{
cbAgent.SelectedIndex = i;
break;
}
}
}
if (cbAgent.SelectedIndex < 0 && cbAgent.Items.Count > 0)
cbAgent.SelectedIndex = 0;
}
private void OnCronChanged(object? sender, EventArgs e)
{
var text = txtCron.Text.Trim();
if (string.IsNullOrWhiteSpace(text))
{
lblPreview.Text = "";
return;
}
try
{
var cron = Core.Scheduling.CronExpression.Parse(text);
var next = cron.GetNextOccurrence(DateTime.Now);
lblPreview.Text = next is not null
? $"Nächste Ausführung: {next:dd.MM.yyyy HH:mm}"
: "Kein nächster Zeitpunkt";
lblPreview.ForeColor = Color.Green;
}
catch
{
lblPreview.Text = "Ungültiger Cron-Ausdruck";
lblPreview.ForeColor = Color.Red;
}
}
private void OnOkClick(object? sender, EventArgs e)
{
if (cbAgent.SelectedItem is null)
{
MessageBox.Show("Bitte einen Agenten auswählen.", "Validierung",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
DialogResult = DialogResult.None;
return;
}
if (string.IsNullOrWhiteSpace(txtCron.Text))
{
MessageBox.Show("Bitte einen Cron-Ausdruck eingeben.", "Validierung",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
DialogResult = DialogResult.None;
return;
}
try
{
Core.Scheduling.CronExpression.Parse(txtCron.Text.Trim());
}
catch
{
MessageBox.Show("Ungültiger Cron-Ausdruck.", "Validierung",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
DialogResult = DialogResult.None;
return;
}
if (IsToolJob)
{
if (cbTool.SelectedItem is null)
{
MessageBox.Show("Bitte ein Tool auswählen.", "Validierung",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
DialogResult = DialogResult.None;
return;
}
if (cbJobDef.SelectedItem is null)
{
MessageBox.Show("Bitte einen Job-Typ auswählen.", "Validierung",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
DialogResult = DialogResult.None;
}
}
}
// ─── ComboBox Items ───
private sealed record AgentComboItem(string AgentId, string DisplayName)
{
public override string ToString() => DisplayName;
}
private sealed record ToolComboItem(string ToolName, string Description)
{
public override string ToString() => ToolName;
}
private sealed record JobDefComboItem(string JobTypeId, string DisplayName, string Description)
{
public override string ToString() => $"{DisplayName} ({JobTypeId})";
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+157
View File
@@ -0,0 +1,157 @@
namespace ClawdDotNet
{
partial class frm_AddService
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
lblType = new Label();
cbType = new ComboBox();
lblName = new Label();
txtName = new TextBox();
lblPort = new Label();
txtPort = new TextBox();
lblDesc = new Label();
txtDescription = new TextBox();
btnOk = new Button();
btnCancel = new Button();
SuspendLayout();
//
// lblType
//
lblType.AutoSize = true;
lblType.Location = new Point(16, 18);
lblType.Name = "lblType";
lblType.Size = new Size(85, 20);
lblType.TabIndex = 0;
lblType.Text = "Service-Typ:";
//
// cbType
//
cbType.DropDownStyle = ComboBoxStyle.DropDownList;
cbType.Items.AddRange(new object[] { "StaticFileServer", "ReverseProxy", "Custom" });
cbType.Location = new Point(140, 15);
cbType.Name = "cbType";
cbType.Size = new Size(260, 28);
cbType.TabIndex = 1;
cbType.SelectedIndexChanged += OnTypeChanged;
//
// lblName
//
lblName.AutoSize = true;
lblName.Location = new Point(16, 58);
lblName.Name = "lblName";
lblName.Size = new Size(49, 20);
lblName.TabIndex = 2;
lblName.Text = "Name:";
//
// txtName
//
txtName.Location = new Point(140, 55);
txtName.Name = "txtName";
txtName.Size = new Size(260, 27);
txtName.TabIndex = 3;
//
// lblPort
//
lblPort.AutoSize = true;
lblPort.Location = new Point(16, 98);
lblPort.Name = "lblPort";
lblPort.Size = new Size(37, 20);
lblPort.TabIndex = 4;
lblPort.Text = "Port:";
//
// txtPort
//
txtPort.Location = new Point(140, 95);
txtPort.Name = "txtPort";
txtPort.Size = new Size(100, 27);
txtPort.TabIndex = 5;
txtPort.Text = "8090";
//
// lblDesc
//
lblDesc.AutoSize = true;
lblDesc.Location = new Point(16, 138);
lblDesc.Name = "lblDesc";
lblDesc.Size = new Size(101, 20);
lblDesc.TabIndex = 6;
lblDesc.Text = "Beschreibung:";
//
// txtDescription
//
txtDescription.Location = new Point(140, 135);
txtDescription.Name = "txtDescription";
txtDescription.Size = new Size(260, 27);
txtDescription.TabIndex = 7;
//
// btnOk
//
btnOk.Location = new Point(200, 185);
btnOk.Name = "btnOk";
btnOk.Size = new Size(100, 30);
btnOk.TabIndex = 8;
btnOk.Text = "Hinzufügen";
btnOk.DialogResult = DialogResult.OK;
btnOk.Click += OnOkClick;
//
// btnCancel
//
btnCancel.Location = new Point(310, 185);
btnCancel.Name = "btnCancel";
btnCancel.Size = new Size(90, 30);
btnCancel.TabIndex = 9;
btnCancel.Text = "Abbrechen";
btnCancel.DialogResult = DialogResult.Cancel;
//
// frm_AddService
//
AcceptButton = btnOk;
CancelButton = btnCancel;
ClientSize = new Size(420, 230);
Controls.Add(lblType);
Controls.Add(cbType);
Controls.Add(lblName);
Controls.Add(txtName);
Controls.Add(lblPort);
Controls.Add(txtPort);
Controls.Add(lblDesc);
Controls.Add(txtDescription);
Controls.Add(btnOk);
Controls.Add(btnCancel);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
Name = "frm_AddService";
StartPosition = FormStartPosition.CenterParent;
Text = "Service hinzufügen";
ResumeLayout(false);
PerformLayout();
}
#endregion
private Label lblType;
private ComboBox cbType;
private Label lblName;
private TextBox txtName;
private Label lblPort;
private TextBox txtPort;
private Label lblDesc;
private TextBox txtDescription;
private Button btnOk;
private Button btnCancel;
}
}
+50
View File
@@ -0,0 +1,50 @@
namespace ClawdDotNet;
public sealed partial class frm_AddService : Form
{
public string ServiceName => txtName.Text.Trim();
public string ServiceType => cbType.SelectedItem?.ToString() ?? "Custom";
public int ServicePort => int.TryParse(txtPort.Text, out var p) ? p : 0;
public string ServiceDescription => txtDescription.Text.Trim();
public frm_AddService()
{
InitializeComponent();
cbType.SelectedIndex = 0;
OnTypeChanged(this, EventArgs.Empty);
}
private void OnTypeChanged(object? sender, EventArgs e)
{
var type = cbType.SelectedItem?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(txtName.Text) || txtName.Text.StartsWith("Neuer "))
txtName.Text = $"Neuer {type}";
txtDescription.Text = type switch
{
"StaticFileServer" => "Statischer Datei-Server",
"ReverseProxy" => "Reverse-Proxy zu einem externen Service",
"Custom" => "Benutzerdefinierter Service",
_ => ""
};
}
private void OnOkClick(object? sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(txtName.Text))
{
MessageBox.Show("Bitte einen Namen eingeben.", "Validierung",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
DialogResult = DialogResult.None;
return;
}
if (!int.TryParse(txtPort.Text, out var port) || port < 1 || port > 65535)
{
MessageBox.Show("Bitte einen gültigen Port (1-65535) eingeben.", "Validierung",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
DialogResult = DialogResult.None;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+111
View File
@@ -0,0 +1,111 @@
namespace ClawdDotNet
{
partial class frm_CreateInstance
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
lblInfo = new Label();
txtName = new TextBox();
lblPreview = new Label();
btnOk = new Button();
btnCancel = new Button();
SuspendLayout();
//
// lblInfo
//
lblInfo.Font = new Font("Segoe UI", 10F);
lblInfo.ForeColor = Color.White;
lblInfo.Location = new Point(20, 20);
lblInfo.Name = "lblInfo";
lblInfo.Size = new Size(380, 25);
lblInfo.TabIndex = 0;
lblInfo.Text = "Name der neuen Instanz:";
//
// txtName
//
txtName.Font = new Font("Segoe UI", 11F);
txtName.Location = new Point(20, 50);
txtName.Name = "txtName";
txtName.Size = new Size(390, 32);
txtName.TabIndex = 1;
txtName.TextChanged += OnTxtNameTextChanged;
//
// lblPreview
//
lblPreview.Font = new Font("Segoe UI", 9F);
lblPreview.ForeColor = Color.Gray;
lblPreview.Location = new Point(20, 90);
lblPreview.Name = "lblPreview";
lblPreview.Size = new Size(390, 25);
lblPreview.TabIndex = 2;
lblPreview.Text = "Ordner: Instance-...";
//
// btnOk
//
btnOk.BackColor = Color.FromArgb(60, 130, 60);
btnOk.FlatStyle = FlatStyle.Flat;
btnOk.ForeColor = Color.White;
btnOk.Location = new Point(220, 130);
btnOk.Name = "btnOk";
btnOk.Size = new Size(90, 35);
btnOk.TabIndex = 3;
btnOk.Text = "Erstellen";
btnOk.UseVisualStyleBackColor = false;
btnOk.DialogResult = DialogResult.OK;
//
// btnCancel
//
btnCancel.BackColor = Color.FromArgb(80, 80, 80);
btnCancel.FlatStyle = FlatStyle.Flat;
btnCancel.ForeColor = Color.White;
btnCancel.Location = new Point(320, 130);
btnCancel.Name = "btnCancel";
btnCancel.Size = new Size(90, 35);
btnCancel.TabIndex = 4;
btnCancel.Text = "Abbrechen";
btnCancel.UseVisualStyleBackColor = false;
btnCancel.DialogResult = DialogResult.Cancel;
//
// frm_CreateInstance
//
AcceptButton = btnOk;
BackColor = Color.FromArgb(45, 45, 45);
CancelButton = btnCancel;
ClientSize = new Size(430, 180);
Controls.Add(lblInfo);
Controls.Add(txtName);
Controls.Add(lblPreview);
Controls.Add(btnOk);
Controls.Add(btnCancel);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
Name = "frm_CreateInstance";
StartPosition = FormStartPosition.CenterParent;
Text = "Neue Instanz erstellen";
ResumeLayout(false);
PerformLayout();
}
#endregion
private Label lblInfo;
private TextBox txtName;
private Label lblPreview;
private Button btnOk;
private Button btnCancel;
}
}
+21
View File
@@ -0,0 +1,21 @@
using ClawdDotNet.Services;
namespace ClawdDotNet;
public sealed partial class frm_CreateInstance : Form
{
public string InstanceName => txtName.Text.Trim();
public frm_CreateInstance()
{
InitializeComponent();
}
private void OnTxtNameTextChanged(object? sender, EventArgs e)
{
var name = txtName.Text.Trim();
lblPreview.Text = string.IsNullOrWhiteSpace(name)
? "Ordner: Instance-..."
: $"Ordner: {InstanceDirectoryManager.BuildInstanceFolderName(name)}";
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+117
View File
@@ -0,0 +1,117 @@
namespace ClawdDotNet
{
partial class frm_InstanceManager
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
components = new System.ComponentModel.Container();
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(frm_InstanceManager));
toolStrip1 = new ToolStrip();
btn_startInstance = new ToolStripButton();
toolStripSeparator1 = new ToolStripSeparator();
btn_createInstance = new ToolStripButton();
dgv_instances = new DataGridView();
notifyIcon1 = new NotifyIcon(components);
toolStrip1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)dgv_instances).BeginInit();
SuspendLayout();
//
// toolStrip1
//
toolStrip1.ImageScalingSize = new Size(24, 24);
toolStrip1.Items.AddRange(new ToolStripItem[] { btn_startInstance, toolStripSeparator1, btn_createInstance });
toolStrip1.Location = new Point(0, 0);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new Size(783, 34);
toolStrip1.TabIndex = 0;
toolStrip1.Text = "toolStrip1";
//
// btn_startInstance
//
btn_startInstance.Image = Properties.Resources.server_go;
btn_startInstance.ImageTransparentColor = Color.Magenta;
btn_startInstance.Name = "btn_startInstance";
btn_startInstance.Size = new Size(146, 29);
btn_startInstance.Text = "Start Instance";
//
// toolStripSeparator1
//
toolStripSeparator1.Name = "toolStripSeparator1";
toolStripSeparator1.Size = new Size(6, 34);
//
// btn_createInstance
//
btn_createInstance.Alignment = ToolStripItemAlignment.Right;
btn_createInstance.Image = Properties.Resources.server_add;
btn_createInstance.ImageTransparentColor = Color.Magenta;
btn_createInstance.Name = "btn_createInstance";
btn_createInstance.Size = new Size(200, 29);
btn_createInstance.Text = "Create New Instance";
//
// dgv_instances
//
dgv_instances.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize;
dgv_instances.Dock = DockStyle.Bottom;
dgv_instances.Location = new Point(0, 45);
dgv_instances.Name = "dgv_instances";
dgv_instances.RowHeadersWidth = 62;
dgv_instances.Size = new Size(783, 408);
dgv_instances.TabIndex = 1;
//
// notifyIcon1
//
notifyIcon1.Text = "notifyIcon1";
notifyIcon1.Visible = true;
//
// frm_InstanceManager
//
AutoScaleDimensions = new SizeF(10F, 25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(783, 453);
Controls.Add(dgv_instances);
Controls.Add(toolStrip1);
HelpButton = true;
Icon = (Icon)resources.GetObject("$this.Icon");
Name = "frm_InstanceManager";
Text = "ClawdDotNet - Instance Manager";
toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout();
((System.ComponentModel.ISupportInitialize)dgv_instances).EndInit();
ResumeLayout(false);
PerformLayout();
}
#endregion
private ToolStrip toolStrip1;
private DataGridView dgv_instances;
private ToolStripButton btn_createInstance;
private ToolStripSeparator toolStripSeparator1;
private ToolStripButton btn_startInstance;
private NotifyIcon notifyIcon1;
}
}
+165
View File
@@ -0,0 +1,165 @@
using ClawdDotNet.Models;
using ClawdDotNet.Services;
namespace ClawdDotNet;
public partial class frm_InstanceManager : Form
{
private readonly InstanceDirectoryManager _dirManager;
private readonly SettingsManager _settingsManager;
private readonly BindingSource _bindingSource = new();
/// <summary>
/// Wird gesetzt, wenn der Benutzer eine Instanz zum Starten ausgewählt hat.
/// Program.cs liest diesen Wert nach DialogResult.OK aus.
/// </summary>
public string? SelectedInstancePath { get; private set; }
public frm_InstanceManager(InstanceDirectoryManager dirManager, SettingsManager settingsManager)
{
_dirManager = dirManager;
_settingsManager = settingsManager;
InitializeComponent();
SetupForm();
}
// Parameterloser Konstruktor für Designer
public frm_InstanceManager()
{
_dirManager = new InstanceDirectoryManager("./Instances");
_settingsManager = new SettingsManager();
InitializeComponent();
}
private void SetupForm()
{
Text = "ClawdDotNet - Instance Manager";
// DataGridView konfigurieren
dgv_instances.AutoGenerateColumns = false;
dgv_instances.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
dgv_instances.MultiSelect = false;
dgv_instances.AllowUserToAddRows = false;
dgv_instances.AllowUserToDeleteRows = false;
dgv_instances.ReadOnly = true;
dgv_instances.BackgroundColor = Color.FromArgb(45, 45, 45);
dgv_instances.DefaultCellStyle.BackColor = Color.FromArgb(55, 55, 55);
dgv_instances.DefaultCellStyle.ForeColor = Color.White;
dgv_instances.DefaultCellStyle.SelectionBackColor = Color.FromArgb(80, 120, 200);
dgv_instances.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(35, 35, 35);
dgv_instances.ColumnHeadersDefaultCellStyle.ForeColor = Color.White;
dgv_instances.EnableHeadersVisualStyles = false;
dgv_instances.Dock = DockStyle.Fill;
// Spalten
dgv_instances.Columns.AddRange(
new DataGridViewTextBoxColumn
{
DataPropertyName = "InstanceName",
HeaderText = "Instanzname",
Width = 200
},
new DataGridViewTextBoxColumn
{
DataPropertyName = "FolderName",
HeaderText = "Ordner",
Width = 200
},
new DataGridViewTextBoxColumn
{
DataPropertyName = "AgentCount",
HeaderText = "Agenten",
Width = 80
},
new DataGridViewTextBoxColumn
{
DataPropertyName = "ApiKeyStatus",
HeaderText = "API-Key",
Width = 120
}
);
dgv_instances.DataSource = _bindingSource;
dgv_instances.DoubleClick += OnInstanceDoubleClick;
// Buttons verdrahten
btn_startInstance.Click += OnStartInstanceClick;
btn_createInstance.Click += OnCreateInstanceClick;
// Daten laden
RefreshInstanceList();
}
private void RefreshInstanceList()
{
var instances = _dirManager.ListInstances();
_bindingSource.DataSource = instances;
dgv_instances.Refresh();
}
private void OnStartInstanceClick(object? sender, EventArgs e)
{
StartSelectedInstance();
}
private void OnInstanceDoubleClick(object? sender, EventArgs e)
{
StartSelectedInstance();
}
private void StartSelectedInstance()
{
var info = GetSelectedInstance();
if (info is null)
{
MessageBox.Show("Bitte wähle eine Instanz aus.", "Hinweis",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
SelectedInstancePath = info.FolderPath;
DialogResult = DialogResult.OK;
Close();
}
private void OnCreateInstanceClick(object? sender, EventArgs e)
{
using var dialog = new frm_CreateInstance();
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
var instanceName = dialog.InstanceName;
if (string.IsNullOrWhiteSpace(instanceName))
{
MessageBox.Show("Bitte gib einen Instanznamen ein.", "Fehler",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
_dirManager.CreateInstance(instanceName);
RefreshInstanceList();
MessageBox.Show(
$"Instanz '{instanceName}' wurde erfolgreich erstellt.",
"Instanz erstellt", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Fehler beim Erstellen der Instanz:\n{ex.Message}",
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private InstanceInfo? GetSelectedInstance()
{
if (dgv_instances.SelectedRows.Count == 0)
return null;
return dgv_instances.SelectedRows[0].DataBoundItem as InstanceInfo;
}
}
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
namespace ClawdDotNet
{
partial class frm_chat
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(frm_chat));
webView_chat2 = new Microsoft.Web.WebView2.WinForms.WebView2();
((System.ComponentModel.ISupportInitialize)webView_chat2).BeginInit();
SuspendLayout();
//
// webView_chat2
//
webView_chat2.AllowExternalDrop = true;
webView_chat2.CreationProperties = null;
webView_chat2.DefaultBackgroundColor = Color.White;
webView_chat2.Dock = DockStyle.Fill;
webView_chat2.Location = new Point(0, 0);
webView_chat2.Name = "webView_chat2";
webView_chat2.Size = new Size(1003, 706);
webView_chat2.TabIndex = 0;
webView_chat2.ZoomFactor = 1D;
//
// frm_chat
//
AutoScaleDimensions = new SizeF(10F, 25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(1003, 706);
Controls.Add(webView_chat2);
Icon = (Icon)resources.GetObject("$this.Icon");
Name = "frm_chat";
Text = "ClawdDotNet - Chat: AgentName";
((System.ComponentModel.ISupportInitialize)webView_chat2).EndInit();
ResumeLayout(false);
}
#endregion
private Microsoft.Web.WebView2.WinForms.WebView2 webView_chat2;
}
}
+165
View File
@@ -0,0 +1,165 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.UI;
using Microsoft.Extensions.Logging;
using Microsoft.Web.WebView2.Core;
namespace ClawdDotNet;
public partial class frm_chat : Form
{
private readonly AgentConfig _agentConfig;
private readonly AgentEngine _engine;
private readonly string _instanceId;
private readonly ILogger _logger;
private WebViewBridge? _bridge;
public frm_chat(AgentConfig agentConfig, AgentEngine engine,
string instanceId, ILoggerFactory loggerFactory)
{
_agentConfig = agentConfig;
_engine = engine;
_instanceId = instanceId;
_logger = loggerFactory.CreateLogger($"ClawdDotNet.UI.Chat.{agentConfig.AgentId}");
InitializeComponent();
Text = $"Chat {agentConfig.DisplayName}";
Load += async (_, _) => await InitWebViewAsync();
}
public frm_chat()
{
_agentConfig = new AgentConfig();
_engine = null!;
_instanceId = "";
_logger = LoggerFactory.Create(_ => { }).CreateLogger("Design");
InitializeComponent();
}
private async Task InitWebViewAsync()
{
try
{
await webView_chat2.EnsureCoreWebView2Async();
var uiPath = EmbeddedUiManager.GetExtractedPath();
webView_chat2.CoreWebView2.SetVirtualHostNameToFolderMapping(
"ui.clwd.internal", uiPath,
CoreWebView2HostResourceAccessKind.DenyCors);
_bridge = new WebViewBridge(webView_chat2, _logger);
_bridge.MessageReceived += OnBridgeMessage;
webView_chat2.CoreWebView2.Navigate(
$"https://ui.clwd.internal/chat.html?agent={_agentConfig.AgentId}");
await Task.Delay(500);
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.AgentListUpdate,
AgentId: _agentConfig.AgentId,
Extra: new
{
agents = new[]
{
new
{
agentId = _agentConfig.AgentId,
displayName = _agentConfig.DisplayName,
model = _agentConfig.Model
}
}
}));
await LoadChatHistoryAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "frm_chat WebView2 init failed");
}
}
private async void OnBridgeMessage(BridgeMessage msg)
{
if (InvokeRequired) { Invoke(() => OnBridgeMessage(msg)); return; }
switch (msg.Type)
{
case BridgeTypes.UserMessage:
if (msg.Content is not null)
await HandleUserMessageAsync(msg.Content);
break;
case BridgeTypes.RunNow:
await HandleUserMessageAsync("Führe deine zugewiesenen Aufgaben aus.");
break;
case BridgeTypes.AbortRun:
_engine.AbortChat(_agentConfig.AgentId);
break;
}
}
private async Task HandleUserMessageAsync(string text)
{
if (_bridge is null) return;
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatMessage,
AgentId: _agentConfig.AgentId,
Content: text,
Extra: new { role = "user", timestamp = DateTime.Now }));
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatTyping,
AgentId: _agentConfig.AgentId));
_ = Task.Run(async () =>
{
try
{
var result = await _engine.ChatAsync(
_agentConfig, text, _instanceId, CancellationToken.None,
source: ChatSource.WebView);
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatMessage,
AgentId: _agentConfig.AgentId,
Content: result.FinalMessage ?? "[Keine Antwort]",
Extra: new { role = "assistant", timestamp = DateTime.Now }));
}
catch (Exception ex)
{
_logger.LogError(ex, "Chat failed");
await _bridge!.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatMessage,
AgentId: _agentConfig.AgentId,
Content: $"[Fehler: {ex.Message}]",
Extra: new { role = "assistant", timestamp = DateTime.Now }));
}
});
}
private async Task LoadChatHistoryAsync()
{
if (_bridge is null) return;
var history = _engine.GetChatHistory(_agentConfig.AgentId);
await _bridge.SendAsync(new BridgeMessage(
Type: BridgeTypes.ChatHistory,
AgentId: _agentConfig.AgentId,
Extra: history.Select(e => new
{
role = e.Role,
content = e.Content,
timestamp = e.Timestamp
}).ToArray()));
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_bridge?.Dispose();
base.OnFormClosed(e);
}
}
+3083
View File
File diff suppressed because it is too large Load Diff
+1451
View File
File diff suppressed because it is too large Load Diff
+1570
View File
File diff suppressed because it is too large Load Diff
+306
View File
@@ -0,0 +1,306 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="splitContainer1.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Fill</value>
</data>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="splitContainer1.Location" type="System.Drawing.Point, System.Drawing">
<value>3, 3</value>
</data>
<data name="splitContainer1.Orientation" type="System.Windows.Forms.Orientation, System.Windows.Forms">
<value>Horizontal</value>
</data>
<data name="toolStrip_agentSettings.Size" type="System.Drawing.Size, System.Drawing">
<value>1884, 25</value>
</data>
<data name="toolStrip_agentSettings.Text" xml:space="preserve">
<value>toolStrip2</value>
</data>
<data name="dgv_agentlist.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Bottom</value>
</data>
<data name="dgv_agentlist.Location" type="System.Drawing.Point, System.Drawing">
<value>0, 26</value>
</data>
<data name="dgv_agentlist.Size" type="System.Drawing.Size, System.Drawing">
<value>1884, 310</value>
</data>
<data name="pg_agentsettings.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Fill</value>
</data>
<data name="pg_agentsettings.Size" type="System.Drawing.Size, System.Drawing">
<value>1884, 547</value>
</data>
<data name="splitContainer1.Size" type="System.Drawing.Size, System.Drawing">
<value>1884, 887</value>
</data>
<assembly alias="mscorlib" name="mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="splitContainer1.SplitterDistance" type="System.Int32, mscorlib">
<value>336</value>
</data>
<data name="splitContainer1.TabIndex" type="System.Int32, mscorlib">
<value>0</value>
</data>
<data name="btn_showInstanceManager.Text" xml:space="preserve">
<value>Show Instance Manager</value>
</data>
<data name="instanceToolStripMenuItem.Text" xml:space="preserve">
<value>Instances</value>
</data>
<data name="label_openRouterStatus.Text" xml:space="preserve">
<value>toolStripStatusLabel1</value>
</data>
<data name="toolStripStatusLabel1.Text" xml:space="preserve">
<value>|</value>
</data>
<data name="label_openRouterCredits.Text" xml:space="preserve">
<value>toolStripStatusLabel2</value>
</data>
<data name="statusStrip1.Location" type="System.Drawing.Point, System.Drawing">
<value>0, 992</value>
</data>
<data name="statusStrip1.Size" type="System.Drawing.Size, System.Drawing">
<value>1898, 32</value>
</data>
<data name="tabControl1.Anchor" type="System.Windows.Forms.AnchorStyles, System.Windows.Forms">
<value>Top, Left</value>
</data>
<data name="webView_chat.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Fill</value>
</data>
<data name="webView_chat.Location" type="System.Drawing.Point, System.Drawing">
<value>3, 3</value>
</data>
<data name="webView_chat.Size" type="System.Drawing.Size, System.Drawing">
<value>1884, 887</value>
</data>
<data name="tabPage_Chat.Size" type="System.Drawing.Size, System.Drawing">
<value>1890, 893</value>
</data>
<data name="rtb_log.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Fill</value>
</data>
<data name="rtb_log.Location" type="System.Drawing.Point, System.Drawing">
<value>0, 34</value>
</data>
<data name="rtb_log.Size" type="System.Drawing.Size, System.Drawing">
<value>1890, 859</value>
</data>
<data name="label_InfoLogModule.Size" type="System.Drawing.Size, System.Drawing">
<value>77, 29</value>
</data>
<data name="label_InfoLogModule.Text" xml:space="preserve">
<value>Module:</value>
</data>
<data name="cb_LogModule.Size" type="System.Drawing.Size, System.Drawing">
<value>221, 34</value>
</data>
<data name="toolStripSeparator1.Size" type="System.Drawing.Size, System.Drawing">
<value>6, 34</value>
</data>
<data name="label_InfoLogLevel.Size" type="System.Drawing.Size, System.Drawing">
<value>85, 29</value>
</data>
<data name="label_InfoLogLevel.Text" xml:space="preserve">
<value>LogLevel:</value>
</data>
<data name="cb_LogLevel.Size" type="System.Drawing.Size, System.Drawing">
<value>220, 34</value>
</data>
<data name="btn_logfolder.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAEESURBVEhL3ZKvDoJQFId5Dk0238GGr2DXbqBa3Cw6uy+g
RZozaTM4gxvZQHA6kc05/2CAetzP7bIrF0Hk3iLbx91O+L5xLloQBKQSDS/TNMkwDKnAGQYw0HVdKnAK
Ae/+kIK0wPV0JHvWI3veo4M1kR+wBjot24UQFpEW4OUAX6I2MJMc2K2GoRzrwp18HXDOHjUXp9cZFaeR
GoC0OnWpODq8zqyRxMDGvYdyRtbIx8B6f6Py2HmT/xKJDVRqjY/ytAhm/FwI1FtdKg23gjCOaITdFz8X
An3rIoiSYDL+Z2BzrDl3gMmiPwPAmrHu3IEksG6sXVkAwPlHAZVovu93VKKpfp4ISreGcqlKAwAAAABJ
RU5ErkJggg==
</value>
</data>
<data name="btn_logfolder.ImageTransparentColor" type="System.Drawing.Color, System.Drawing">
<value>Magenta</value>
</data>
<data name="btn_logfolder.Text" xml:space="preserve">
<value>Open LogFolder</value>
</data>
<data name="toolStrip3.Size" type="System.Drawing.Size, System.Drawing">
<value>1890, 34</value>
</data>
<data name="toolStrip3.Text" xml:space="preserve">
<value>toolStrip3</value>
</data>
<data name="tabPage_Log.Size" type="System.Drawing.Size, System.Drawing">
<value>1890, 893</value>
</data>
<data name="pg_appsettings.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Fill</value>
</data>
<data name="pg_appsettings.Size" type="System.Drawing.Size, System.Drawing">
<value>1870, 843</value>
</data>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="tabPage_appsettings.Padding" type="System.Windows.Forms.Padding, System.Windows.Forms">
<value>3, 3, 3, 3</value>
</data>
<data name="tabPage_appsettings.Size" type="System.Drawing.Size, System.Drawing">
<value>1876, 849</value>
</data>
<data name="tabPage_appsettings.Text" xml:space="preserve">
<value>General Settings</value>
</data>
<data name="pg_instancesettings.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Fill</value>
</data>
<data name="pg_instancesettings.Size" type="System.Drawing.Size, System.Drawing">
<value>1870, 843</value>
</data>
<data name="tabPage_instanceettings.Padding" type="System.Windows.Forms.Padding, System.Windows.Forms">
<value>3, 3, 3, 3</value>
</data>
<data name="tabPage_instanceettings.Size" type="System.Drawing.Size, System.Drawing">
<value>1876, 849</value>
</data>
<data name="tabPage_instanceettings.Text" xml:space="preserve">
<value>Instance Settings</value>
</data>
<data name="tabControl2.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Fill</value>
</data>
<data name="tabControl2.Size" type="System.Drawing.Size, System.Drawing">
<value>1884, 887</value>
</data>
<data name="tabPage_GeneralSettings.Size" type="System.Drawing.Size, System.Drawing">
<value>1890, 893</value>
</data>
<data name="tabPage_AgentSettings.Size" type="System.Drawing.Size, System.Drawing">
<value>1890, 893</value>
</data>
<data name="tabControl1.Dock" type="System.Windows.Forms.DockStyle, System.Windows.Forms">
<value>Bottom</value>
</data>
<data name="tabControl1.Size" type="System.Drawing.Size, System.Drawing">
<value>1898, 931</value>
</data>
<data name="$this.Text" xml:space="preserve">
<value>ClawdDotNet - InstanceName</value>
</data>
</root>
+3104
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ChatMessage
{
[JsonPropertyName("role")]
public string Role { get; set; } = "";
[JsonPropertyName("content")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Content { get; set; }
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<ToolCall>? ToolCalls { get; set; }
[JsonPropertyName("tool_call_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }
public static ChatMessage System(string content) => new() { Role = "system", Content = content };
public static ChatMessage User(string content) => new() { Role = "user", Content = content };
public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content };
public static ChatMessage AssistantWithToolCalls(List<ToolCall> toolCalls) => new()
{
Role = "assistant",
ToolCalls = toolCalls
};
public static ChatMessage ToolResponse(string toolCallId, string content) => new()
{
Role = "tool",
ToolCallId = toolCallId,
Content = content
};
}
@@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ChatRequest
{
[JsonPropertyName("model")]
public string Model { get; set; } = "";
[JsonPropertyName("messages")]
public List<ChatMessage> Messages { get; set; } = new();
[JsonPropertyName("tools")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<ToolDefinition>? Tools { get; set; }
[JsonPropertyName("tool_choice")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ToolChoice { get; set; }
[JsonPropertyName("temperature")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Temperature { get; set; }
[JsonPropertyName("max_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxTokens { get; set; }
[JsonPropertyName("stream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Stream { get; set; }
}
@@ -0,0 +1,54 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ChatResponse
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("choices")]
public List<Choice> Choices { get; set; } = new();
[JsonPropertyName("usage")]
public Usage? Usage { get; set; }
[JsonPropertyName("model")]
public string? Model { get; set; }
[JsonPropertyName("error")]
public ApiError? Error { get; set; }
}
public sealed class Choice
{
[JsonPropertyName("index")]
public int Index { get; set; }
[JsonPropertyName("message")]
public ChatMessage Message { get; set; } = new();
[JsonPropertyName("finish_reason")]
public string? FinishReason { get; set; }
}
public sealed class Usage
{
[JsonPropertyName("prompt_tokens")]
public int PromptTokens { get; set; }
[JsonPropertyName("completion_tokens")]
public int CompletionTokens { get; set; }
[JsonPropertyName("total_tokens")]
public int TotalTokens { get; set; }
}
public sealed class ApiError
{
[JsonPropertyName("message")]
public string Message { get; set; } = "";
[JsonPropertyName("code")]
public int? Code { get; set; }
}
@@ -0,0 +1,15 @@
namespace ClawdDotNet.Core.Api.Models;
/// <summary>
/// Repräsentiert ein verfügbares LLM-Modell von OpenRouter.
/// </summary>
public sealed class ModelInfo
{
/// <summary>OpenRouter Modell-ID (z.B. "anthropic/claude-sonnet-4-5")</summary>
public string Id { get; set; } = "";
/// <summary>Anzeigename des Modells</summary>
public string Name { get; set; } = "";
public override string ToString() => Id;
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ToolCall
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "function";
[JsonPropertyName("function")]
public ToolCallFunction Function { get; set; } = new();
}
public sealed class ToolCallFunction
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("arguments")]
public string Arguments { get; set; } = "";
}
@@ -0,0 +1,25 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ToolDefinition
{
[JsonPropertyName("type")]
public string Type { get; set; } = "function";
[JsonPropertyName("function")]
public FunctionDefinition Function { get; set; } = new();
}
public sealed class FunctionDefinition
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("parameters")]
public JsonElement Parameters { get; set; }
}
@@ -0,0 +1,120 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Api.Models;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Api;
public sealed class OpenRouterClient : IDisposable
{
private const string BaseUrl = "https://openrouter.ai/api/v1/";
private readonly HttpClient _http;
private readonly ILogger _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public OpenRouterClient(string apiKey, ILogger logger, HttpClient? httpClient = null)
{
_logger = logger;
_http = httpClient ?? new HttpClient();
_http.BaseAddress = new Uri(BaseUrl);
_http.Timeout = TimeSpan.FromMinutes(5); // LLM-Calls können bei großen Prompts lange dauern
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
_http.DefaultRequestHeaders.Add("X-Title", "ClawdDotNet");
}
public async Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
{
request.Stream = false;
var json = JsonSerializer.Serialize(request, JsonOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
_logger.LogDebug("Sending request to OpenRouter: model={Model}, messages={Count}",
request.Model, request.Messages.Count);
using var response = await _http.PostAsync("chat/completions", content, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("OpenRouter API error {StatusCode}: {Body}",
(int)response.StatusCode, responseBody);
throw new OpenRouterException(
$"API request failed with status {(int)response.StatusCode}",
(int)response.StatusCode,
responseBody);
}
var result = JsonSerializer.Deserialize<ChatResponse>(responseBody, JsonOptions)
?? throw new OpenRouterException("Empty response from OpenRouter", 0, responseBody);
if (result.Error is not null)
{
_logger.LogError("OpenRouter returned error: {Error}", result.Error.Message);
throw new OpenRouterException(result.Error.Message, result.Error.Code ?? 0, responseBody);
}
_logger.LogDebug("OpenRouter response received: tokens={Tokens}",
result.Usage?.TotalTokens ?? 0);
return result;
}
/// <summary>
/// Ruft die verfügbaren Modelle von OpenRouter ab (/models Endpoint).
/// Gibt eine Liste von Modell-IDs zurück, sortiert nach Name.
/// </summary>
public async Task<List<ModelInfo>> GetAvailableModelsAsync(CancellationToken ct = default)
{
_logger.LogDebug("Fetching available models from OpenRouter...");
using var response = await _http.GetAsync("models", ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch models: {StatusCode} {Body}",
(int)response.StatusCode, body);
return [];
}
var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("data", out var dataArray))
return [];
var models = new List<ModelInfo>();
foreach (var item in dataArray.EnumerateArray())
{
var id = item.GetProperty("id").GetString() ?? "";
var name = item.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? id : id;
models.Add(new ModelInfo { Id = id, Name = name });
}
models.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.OrdinalIgnoreCase));
_logger.LogDebug("Fetched {Count} models from OpenRouter", models.Count);
return models;
}
public void Dispose()
{
_http.Dispose();
}
}
public sealed class OpenRouterException(string message, int statusCode, string responseBody)
: Exception(message)
{
public int StatusCode { get; } = statusCode;
public string ResponseBody { get; } = responseBody;
}
+7
View File
@@ -0,0 +1,7 @@
namespace ClawdDotNet.Core;
public static class BuildInfo
{
public const int Build = 1;
public const string Changes = "ToolJob-System, ChatAsync/RunAsync, ContextCompaction, OnRunCompleted-Event";
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
</ItemGroup>
</Project>
+124
View File
@@ -0,0 +1,124 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
public sealed class AgentConfig
{
[JsonPropertyName("agentId")]
public string AgentId { get; set; } = "";
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = "";
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
[JsonPropertyName("systemPrompt")]
public string SystemPrompt { get; set; } = "";
/// <summary>
/// Identität des Agenten (aus Identity.md geladen).
/// Definiert WER der Agent ist: Name, Rolle, Hintergrund.
/// </summary>
[JsonIgnore]
public string Identity { get; set; } = "";
/// <summary>
/// Seele des Agenten (aus Soul.md geladen).
/// Definiert WIE der Agent denkt: Persönlichkeit, Werte, Verhaltensmuster.
/// </summary>
[JsonIgnore]
public string Soul { get; set; } = "";
/// <summary>
/// Lokaler Pfad zum Workspace-Verzeichnis des Agenten.
/// Wird zur Laufzeit vom Host gesetzt.
/// </summary>
/// <summary>
/// Kurzbeschreibung der Rolle/Aufgabe des Agenten.
/// Wird aus AgentList.json geladen und bei list_agents angezeigt.
/// </summary>
[JsonIgnore]
public string Description { get; set; } = "";
/// <summary>
/// Absoluter Pfad zum Agent-Verzeichnis (Agent-XYZ).
/// Wird zur Laufzeit gesetzt und bleibt stabil auch bei DisplayName-Änderungen.
/// </summary>
[JsonIgnore]
public string AgentDir { get; set; } = "";
[JsonIgnore]
public string WorkspacePath { get; set; } = "";
/// <summary>
/// Lokaler Pfad zum geteilten Workspace-Verzeichnis (SharedWorkspace).
/// Wird zur Laufzeit vom Host gesetzt.
/// </summary>
[JsonIgnore]
public string SharedWorkspacePath { get; set; } = "";
/// <summary>
/// Baut den vollständigen System-Prompt aus Identity + Soul + SystemPrompt zusammen.
/// </summary>
[JsonIgnore]
public string FullSystemPrompt
{
get
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(Identity))
parts.Add($"# Identity\n{Identity}");
if (!string.IsNullOrWhiteSpace(Soul))
parts.Add($"# Soul\n{Soul}");
if (!string.IsNullOrWhiteSpace(SystemPrompt))
parts.Add(SystemPrompt);
return string.Join("\n\n", parts);
}
}
[JsonPropertyName("tools")]
public Dictionary<string, Dictionary<string, object?>> Tools { get; set; } = new();
[JsonPropertyName("scheduler")]
public SchedulerConfig? Scheduler { get; set; }
[JsonPropertyName("toolJobs")]
public List<ToolJobConfig> ToolJobs { get; set; } = new();
[JsonPropertyName("loopGuard")]
public LoopGuardConfig LoopGuard { get; set; } = new();
}
public sealed class SchedulerConfig
{
[JsonPropertyName("cron")]
public string Cron { get; set; } = "";
[JsonPropertyName("runOnStart")]
public bool RunOnStart { get; set; }
[JsonPropertyName("taskMessage")]
public string TaskMessage { get; set; } = "Führe deine zugewiesenen Aufgaben aus.";
}
public sealed class LoopGuardConfig
{
[JsonPropertyName("maxSteps")]
public int MaxSteps { get; set; } = 20;
[JsonPropertyName("maxTokens")]
public int MaxTokens { get; set; } = 80_000;
[JsonPropertyName("timeoutSeconds")]
public int TimeoutSeconds { get; set; } = 600;
[JsonPropertyName("maxContextTokens")]
public int MaxContextTokens { get; set; } = 100_000;
[JsonPropertyName("compactionThreshold")]
public double CompactionThreshold { get; set; } = 0.80;
[JsonIgnore]
public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
}
@@ -0,0 +1,42 @@
using System.Text.Json;
namespace ClawdDotNet.Core.Config;
public static class ConfigLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true
};
public static async Task<InstanceConfig> LoadAsync(string filePath, CancellationToken ct = default)
{
if (!File.Exists(filePath))
throw new FileNotFoundException($"Config file not found: {filePath}");
await using var stream = File.OpenRead(filePath);
var config = await JsonSerializer.DeserializeAsync<InstanceConfig>(stream, JsonOptions, ct)
?? throw new InvalidOperationException($"Config file is empty or invalid: {filePath}");
Validate(config, filePath);
return config;
}
private static void Validate(InstanceConfig config, string filePath)
{
if (string.IsNullOrWhiteSpace(config.OpenRouterApiKey))
throw new InvalidOperationException($"'openRouterApiKey' is required in {filePath}");
var agentIds = new HashSet<string>();
foreach (var agent in config.Agents)
{
if (string.IsNullOrWhiteSpace(agent.AgentId))
throw new InvalidOperationException($"Every agent must have an 'agentId' in {filePath}");
if (!agentIds.Add(agent.AgentId))
throw new InvalidOperationException($"Duplicate agentId '{agent.AgentId}' in {filePath}");
}
}
}
@@ -0,0 +1,48 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
public sealed class InstanceConfig
{
[JsonPropertyName("instanceId")]
public string InstanceId { get; set; } = Guid.NewGuid().ToString("N")[..8];
[JsonPropertyName("instanceName")]
public string InstanceName { get; set; } = "Default";
[JsonPropertyName("openRouterApiKey")]
public string OpenRouterApiKey { get; set; } = "";
[JsonPropertyName("workingDirectory")]
public string WorkingDirectory { get; set; } = "./data/";
[JsonPropertyName("logDirectory")]
public string LogDirectory { get; set; } = "./Logs";
[JsonPropertyName("webServerPort")]
public int WebServerPort { get; set; } = 8080;
[JsonPropertyName("telegramClient")]
public TelegramClientConfig? TelegramClient { get; set; }
[JsonPropertyName("agents")]
public List<AgentConfig> Agents { get; set; } = new();
[JsonPropertyName("services")]
public List<ServiceConfig> Services { get; set; } = new();
}
public sealed class TelegramClientConfig
{
[JsonPropertyName("apiId")]
public int ApiId { get; set; }
[JsonPropertyName("apiHash")]
public string ApiHash { get; set; } = "";
[JsonPropertyName("phoneNumber")]
public string PhoneNumber { get; set; } = "";
[JsonPropertyName("password2FA")]
public string? Password2FA { get; set; }
}
@@ -0,0 +1,74 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
public sealed class ServiceConfig
{
[JsonPropertyName("serviceId")]
public string ServiceId { get; set; } = Guid.NewGuid().ToString("N")[..8];
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("autoStart")]
public bool AutoStart { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("builtIn")]
public bool BuiltIn { get; set; }
}
public static class BuiltInServices
{
public const string AgentChatWebUI = "AgentChatWebUI";
public const string AgentWebsite = "AgentWebsite";
public const string ClawdDotNetApi = "ClawdDotNetApi";
public static List<ServiceConfig> CreateDefaults() =>
[
new()
{
ServiceId = "svc_chat",
Name = "Agent Chat WebUI",
Type = AgentChatWebUI,
Port = 5080,
Enabled = true,
AutoStart = true,
BuiltIn = true,
Description = "WebUI für den Agenten-Chat (WebView2)"
},
new()
{
ServiceId = "svc_web",
Name = "Agent Website",
Type = AgentWebsite,
Port = 5081,
Enabled = false,
AutoStart = false,
BuiltIn = true,
Description = "Von Agenten entwickelte und betreute Website"
},
new()
{
ServiceId = "svc_api",
Name = "ClawdDotNet API",
Type = ClawdDotNetApi,
Port = 5082,
Enabled = true,
AutoStart = true,
BuiltIn = true,
Description = "REST-API für die Kommunikation mit der WebApp"
}
];
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
public sealed class ToolJobConfig
{
[JsonPropertyName("jobId")]
public string JobId { get; set; } = Guid.NewGuid().ToString("N")[..8];
[JsonPropertyName("toolName")]
public string ToolName { get; set; } = "";
[JsonPropertyName("jobTypeId")]
public string JobTypeId { get; set; } = "";
[JsonPropertyName("cron")]
public string Cron { get; set; } = "";
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("runOnStart")]
public bool RunOnStart { get; set; }
}
+700
View File
@@ -0,0 +1,700 @@
using System.Diagnostics;
using System.Text.Json;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Core.State;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Engine;
public sealed class AgentEngine : IAgentMessageRouter
{
private readonly OpenRouterClient _client;
private readonly ToolRegistry _toolRegistry;
private readonly PermissionGate _permissionGate;
private readonly IStateStore _stateStore;
private readonly ILoggerFactory _loggerFactory;
private readonly ContextCompactor _compactor;
private readonly Dictionary<string, List<ChatEntry>> _chatHistories = new();
private readonly Dictionary<string, List<ChatMessage>> _chatContexts = new();
private readonly Dictionary<string, CancellationTokenSource> _runningChats = new();
private readonly Lock _lock = new();
private Func<IReadOnlyList<AgentConfig>>? _agentConfigProvider;
private Func<string, string?>? _agentDirResolver;
private string _instanceId = "";
private static readonly JsonSerializerOptions _jsonOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public AgentEngine(
OpenRouterClient client,
ToolRegistry toolRegistry,
PermissionGate permissionGate,
IStateStore stateStore,
ILoggerFactory loggerFactory)
{
_client = client;
_toolRegistry = toolRegistry;
_permissionGate = permissionGate;
_stateStore = stateStore;
_loggerFactory = loggerFactory;
_compactor = new ContextCompactor(client, loggerFactory);
}
public event Action<string, string>? OnStepCompleted;
/// <summary>
/// Wird ausgelöst wenn ein neuer ChatEntry hinzugefügt wird (agentId, role, content, source).
/// Erlaubt der UI, Nachrichten aus Hintergrund-Runs (ToolJobs, AgentComm) live anzuzeigen.
/// Source gibt an, woher die Nachricht kam (webview, telegram, agentcomm, job, null).
/// </summary>
public event Action<string, string, string, string?>? OnChatEntryAdded;
/// <summary>
/// Wird nach jedem abgeschlossenen Run/Chat ausgelöst (model, result).
/// Erlaubt der UI, Token-Verbrauch und Kosten für ALLE Runs zu tracken.
/// </summary>
public event Action<string, AgentRunResult>? OnRunCompleted;
public async Task<AgentRunResult> RunAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken externalCt)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
var sw = Stopwatch.StartNew();
using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt, timeoutCts.Token);
var ct = linkedCts.Token;
logger.LogInformation("Agent run started: {AgentId}, model={Model}",
agentConfig.AgentId, agentConfig.Model);
try
{
var tools = _toolRegistry.GetForAgent(agentConfig);
var toolDefinitions = BuildToolDefinitions(tools);
var messages = new List<ChatMessage>();
var systemPrompt = agentConfig.FullSystemPrompt;
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
messages.Add(ChatMessage.User(userMessage));
string? finalMessage = null;
var totalTokens = 0;
while (true)
{
ct.ThrowIfCancellationRequested();
loopGuard.RecordStep();
var request = new ChatRequest
{
Model = agentConfig.Model,
Messages = messages,
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
};
var response = await _client.CompleteAsync(request, ct);
var promptTokens = 0;
if (response.Usage is not null)
{
totalTokens += response.Usage.TotalTokens;
promptTokens = response.Usage.PromptTokens;
loopGuard.RecordTokens(response.Usage.TotalTokens);
}
// Context-Compaction auch in RunAsync verhindert Token-Explosion bei komplexen Analysen
var compacted = await _compactor.CompactIfNeededAsync(
messages, promptTokens, agentConfig.LoopGuard, agentConfig.Model, ct);
if (compacted)
{
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
}
var choice = response.Choices.FirstOrDefault();
if (choice is null)
{
finalMessage = "[No response from model]";
break;
}
var assistantMessage = choice.Message;
if (assistantMessage.ToolCalls is { Count: > 0 })
{
messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls));
foreach (var toolCall in assistantMessage.ToolCalls)
{
var toolResult = await ExecuteToolCallAsync(
toolCall, agentConfig, instanceId, tools, logger, ct);
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
}
OnStepCompleted?.Invoke(agentConfig.AgentId,
$"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed");
}
else
{
finalMessage = assistantMessage.Content ?? "";
break;
}
}
sw.Stop();
logger.LogInformation(
"Agent run completed: {AgentId}, steps={Steps}, tokens={Tokens}, duration={Duration}ms",
agentConfig.AgentId, loopGuard.Steps, totalTokens, sw.ElapsedMilliseconds);
var result = new AgentRunResult(
agentConfig.AgentId,
AgentRunStatus.Completed,
finalMessage,
loopGuard.Steps,
totalTokens,
sw.Elapsed);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
sw.Stop();
logger.LogWarning("Agent run timed out: {AgentId} after {Duration}ms",
agentConfig.AgentId, sw.ElapsedMilliseconds);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Cancelled, null,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (OperationCanceledException)
{
sw.Stop();
logger.LogInformation("Agent run cancelled: {AgentId}", agentConfig.AgentId);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Cancelled, null,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (LoopLimitExceededException ex)
{
sw.Stop();
logger.LogWarning("Agent run loop limit exceeded: {AgentId}: {Message}",
agentConfig.AgentId, ex.Message);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, null,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (Exception ex)
{
sw.Stop();
logger.LogError(ex, "Agent run failed: {AgentId}", agentConfig.AgentId);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Failed, null,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
}
public async Task<AgentRunResult> ChatAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken externalCt,
string? source = null)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
var sw = Stopwatch.StartNew();
using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt, timeoutCts.Token);
var ct = linkedCts.Token;
lock (_lock)
_runningChats[agentConfig.AgentId] = linkedCts;
try
{
var tools = _toolRegistry.GetForAgent(agentConfig);
var toolDefinitions = BuildToolDefinitions(tools);
List<ChatMessage> messages;
lock (_lock)
{
if (!_chatContexts.TryGetValue(agentConfig.AgentId, out messages!))
{
messages = new List<ChatMessage>();
var systemPrompt = agentConfig.FullSystemPrompt;
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
_chatContexts[agentConfig.AgentId] = messages;
}
}
// Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt
var routedMessage = source switch
{
ChatSource.WebView => $"[WebView Chat antworte als normaler Text, NICHT über Telegram senden]\n{userMessage}",
ChatSource.Telegram => userMessage, // Telegram-Nachrichten kommen bereits mit [Telegram] Prefix vom Job
_ => userMessage
};
messages.Add(ChatMessage.User(routedMessage));
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
string? finalMessage = null;
var totalTokens = 0;
while (true)
{
ct.ThrowIfCancellationRequested();
loopGuard.RecordStep();
var request = new ChatRequest
{
Model = agentConfig.Model,
Messages = messages,
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
};
var response = await _client.CompleteAsync(request, ct);
var promptTokens = 0;
if (response.Usage is not null)
{
totalTokens += response.Usage.TotalTokens;
promptTokens = response.Usage.PromptTokens;
loopGuard.RecordTokens(response.Usage.TotalTokens);
}
// Context-Compaction nach API-Response prüfen
var compacted = await _compactor.CompactIfNeededAsync(
messages, promptTokens, agentConfig.LoopGuard, agentConfig.Model, ct);
if (compacted)
{
PersistChatState(agentConfig.AgentId);
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
}
var choice = response.Choices.FirstOrDefault();
if (choice is null)
{
finalMessage = "[No response from model]";
break;
}
var assistantMessage = choice.Message;
if (assistantMessage.ToolCalls is { Count: > 0 })
{
messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls));
foreach (var toolCall in assistantMessage.ToolCalls)
{
var toolResult = await ExecuteToolCallAsync(
toolCall, agentConfig, instanceId, tools, logger, ct);
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
}
OnStepCompleted?.Invoke(agentConfig.AgentId,
$"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)");
}
else
{
finalMessage = assistantMessage.Content ?? "";
messages.Add(ChatMessage.Assistant(finalMessage));
AddChatEntry(agentConfig.AgentId, "assistant", finalMessage, source);
break;
}
}
sw.Stop();
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Completed, finalMessage,
loopGuard.Steps, totalTokens, sw.Elapsed);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (OperationCanceledException)
{
sw.Stop();
var cancelMsg = "[Chat abgebrochen]";
AddChatEntry(agentConfig.AgentId, "assistant", cancelMsg, source);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Cancelled, cancelMsg,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (LoopLimitExceededException ex)
{
sw.Stop();
var limitMsg = $"[Loop-Limit erreicht: {ex.Message}]";
logger.LogWarning("Chat loop limit: {AgentId}: {Message}", agentConfig.AgentId, ex.Message);
AddChatEntry(agentConfig.AgentId, "assistant", limitMsg, source);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, limitMsg,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
catch (Exception ex)
{
sw.Stop();
var errorMsg = $"[Fehler: {ex.Message}]";
logger.LogError(ex, "Chat failed: {AgentId}", agentConfig.AgentId);
AddChatEntry(agentConfig.AgentId, "assistant", errorMsg, source);
var result = new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Failed, errorMsg,
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
finally
{
lock (_lock)
_runningChats.Remove(agentConfig.AgentId);
}
}
public IReadOnlyList<ChatEntry> GetChatHistory(string agentId)
{
lock (_lock)
return _chatHistories.TryGetValue(agentId, out var history)
? history.ToList().AsReadOnly()
: [];
}
public bool IsRunning(string agentId)
{
lock (_lock)
return _runningChats.ContainsKey(agentId);
}
public void AbortChat(string agentId)
{
lock (_lock)
{
if (_runningChats.TryGetValue(agentId, out var cts))
cts.Cancel();
}
}
public void ClearChatHistory(string agentId)
{
lock (_lock)
{
_chatHistories.Remove(agentId);
_chatContexts.Remove(agentId);
}
var dir = _agentDirResolver?.Invoke(agentId);
if (dir is null) return;
var historyPath = Path.Combine(dir, "ChatHistory.json");
var contextPath = Path.Combine(dir, "ChatContext.json");
if (File.Exists(historyPath)) File.Delete(historyPath);
if (File.Exists(contextPath)) File.Delete(contextPath);
}
public void SetAgentConfigProvider(
Func<IReadOnlyList<AgentConfig>> provider,
string instanceId,
Func<string, string?>? agentDirResolver = null)
{
_agentConfigProvider = provider;
_instanceId = instanceId;
_agentDirResolver = agentDirResolver;
}
public void LoadPersistedChats()
{
var configs = _agentConfigProvider?.Invoke() ?? [];
foreach (var agent in configs)
{
var dir = _agentDirResolver?.Invoke(agent.AgentId);
if (dir is null || !Directory.Exists(dir)) continue;
try
{
var historyPath = Path.Combine(dir, "ChatHistory.json");
if (File.Exists(historyPath))
{
var history = JsonSerializer.Deserialize<List<ChatEntry>>(
File.ReadAllText(historyPath), _jsonOpts);
if (history is { Count: > 0 })
{
lock (_lock)
_chatHistories[agent.AgentId] = history;
}
}
var contextPath = Path.Combine(dir, "ChatContext.json");
if (File.Exists(contextPath))
{
var raw = File.ReadAllText(contextPath);
List<ChatMessage>? context = null;
// Versuche zuerst als Array (direktes List<ChatMessage>)
// Dann als Wrapper-Objekt {"messages":[...]}
try
{
context = JsonSerializer.Deserialize<List<ChatMessage>>(raw, _jsonOpts);
}
catch (JsonException)
{
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.TryGetProperty("messages", out var msgs))
{
context = JsonSerializer.Deserialize<List<ChatMessage>>(
msgs.GetRawText(), _jsonOpts);
}
}
catch { /* Beide Formate fehlgeschlagen — ignorieren */ }
}
if (context is { Count: > 0 })
{
lock (_lock)
_chatContexts[agent.AgentId] = context;
}
}
}
catch (Exception ex)
{
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Persistence")
.LogWarning(ex, "Failed to load chat state for agent {AgentId}", agent.AgentId);
}
}
}
// ─── IAgentMessageRouter ───
public IReadOnlyList<AgentInfo> ListAgents(string callerAgentId)
{
var configs = _agentConfigProvider?.Invoke() ?? [];
return configs
.Where(a => a.AgentId != callerAgentId)
.Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId)))
.ToList();
}
public async Task<AgentMessageResult> SendMessageAsync(
string fromAgentId, string toAgentId, string message, CancellationToken ct)
{
var configs = _agentConfigProvider?.Invoke();
if (configs is null)
return new AgentMessageResult(false, null, "Agent config provider not set.");
var targetConfig = configs.FirstOrDefault(a => a.AgentId == toAgentId);
if (targetConfig is null)
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId);
var fromName = fromConfig?.DisplayName ?? fromAgentId;
var wrappedMessage = $"[Nachricht von Agent \"{fromName}\" ({fromAgentId})]\n\n{message}";
try
{
var result = await ChatAsync(targetConfig, wrappedMessage, _instanceId, ct, source: ChatSource.AgentComm);
return result.Status switch
{
AgentRunStatus.Completed => new AgentMessageResult(true, result.FinalMessage),
AgentRunStatus.LoopLimitExceeded => new AgentMessageResult(true, result.FinalMessage,
"Agent hat das Step-Limit erreicht, die Nachricht wurde aber zugestellt."),
_ => new AgentMessageResult(false, null, $"Agent run status: {result.Status}")
};
}
catch (Exception ex)
{
return new AgentMessageResult(false, null, $"Failed to reach agent: {ex.Message}");
}
}
public async Task<AgentSpawnResult> SpawnAgentAsync(
string fromAgentId, string targetAgentId, string taskMessage, CancellationToken ct)
{
var configs = _agentConfigProvider?.Invoke();
if (configs is null)
return new AgentSpawnResult(false, targetAgentId, null, "Agent config provider not set.");
var targetConfig = configs.FirstOrDefault(a => a.AgentId == targetAgentId);
if (targetConfig is null)
return new AgentSpawnResult(false, targetAgentId, null, $"Agent '{targetAgentId}' not found.");
if (IsRunning(targetAgentId))
return new AgentSpawnResult(false, targetAgentId, null,
$"Agent '{targetConfig.DisplayName}' läuft bereits. Verwende send_message statt spawn.");
var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId);
var fromName = fromConfig?.DisplayName ?? fromAgentId;
var wrappedMessage = $"[Spawn-Auftrag von Agent \"{fromName}\" ({fromAgentId})]\n\n{taskMessage}";
try
{
var result = await RunAsync(targetConfig, wrappedMessage, _instanceId, ct);
return result.Status == AgentRunStatus.Completed
? new AgentSpawnResult(true, targetAgentId, result.FinalMessage)
: new AgentSpawnResult(false, targetAgentId, null, $"Agent run status: {result.Status}");
}
catch (Exception ex)
{
return new AgentSpawnResult(false, targetAgentId, null, $"Spawn failed: {ex.Message}");
}
}
private void AddChatEntry(string agentId, string role, string content, string? source = null)
{
lock (_lock)
{
if (!_chatHistories.TryGetValue(agentId, out var history))
{
history = new List<ChatEntry>();
_chatHistories[agentId] = history;
}
history.Add(new ChatEntry(role, content, DateTime.Now, source));
}
PersistChatState(agentId);
OnChatEntryAdded?.Invoke(agentId, role, content, source);
}
private void PersistChatState(string agentId)
{
var dir = _agentDirResolver?.Invoke(agentId);
if (dir is null) return;
try
{
Directory.CreateDirectory(dir);
List<ChatEntry>? history;
List<ChatMessage>? context;
lock (_lock)
{
_chatHistories.TryGetValue(agentId, out history);
_chatContexts.TryGetValue(agentId, out context);
}
if (history is not null)
File.WriteAllText(
Path.Combine(dir, "ChatHistory.json"),
JsonSerializer.Serialize(history, _jsonOpts));
if (context is not null)
File.WriteAllText(
Path.Combine(dir, "ChatContext.json"),
JsonSerializer.Serialize(context, _jsonOpts));
}
catch (Exception ex)
{
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Persistence")
.LogWarning(ex, "Failed to persist chat state for agent {AgentId}", agentId);
}
}
private async Task<string> ExecuteToolCallAsync(
ToolCall toolCall,
AgentConfig agentConfig,
string instanceId,
IReadOnlyList<IAgentTool> availableTools,
ILogger logger,
CancellationToken ct)
{
var toolName = toolCall.Function.Name;
try
{
_permissionGate.Enforce(agentConfig.AgentId, toolName, agentConfig);
var tool = availableTools.FirstOrDefault(t => t.Name == toolName);
if (tool is null)
return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found."));
var input = string.IsNullOrWhiteSpace(toolCall.Function.Arguments)
? default
: JsonDocument.Parse(toolCall.Function.Arguments).RootElement;
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
var context = new AgentToolContext(
agentConfig.AgentId,
instanceId,
toolConfig,
_stateStore,
toolLogger,
ct,
agentConfig.WorkspacePath,
agentConfig.SharedWorkspacePath,
this);
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
var result = await tool.ExecuteAsync(input, context, ct);
logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success);
return result.Success
? result.Content
: JsonSerializer.Serialize(new { error = result.ErrorMessage });
}
catch (ToolAccessDeniedException ex)
{
logger.LogWarning("Tool access denied: {Message}", ex.Message);
return JsonSerializer.Serialize(new { error = ex.Message });
}
catch (Exception ex)
{
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
}
}
private static List<ToolDefinition> BuildToolDefinitions(IReadOnlyList<IAgentTool> tools)
{
return tools.Select(t => new ToolDefinition
{
Function = new FunctionDefinition
{
Name = t.Name,
Description = t.Description,
Parameters = t.InputSchema
}
}).ToList();
}
}
@@ -0,0 +1,19 @@
namespace ClawdDotNet.Core.Engine;
public sealed record AgentRunResult(
string AgentId,
AgentRunStatus Status,
string? FinalMessage,
int StepCount,
int TokensUsed,
TimeSpan Duration,
Exception? Error = null
);
public enum AgentRunStatus
{
Completed,
Failed,
Cancelled,
LoopLimitExceeded
}
+21
View File
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Engine;
public sealed record ChatEntry(
[property: JsonPropertyName("role")] string Role,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("timestamp")] DateTime Timestamp,
[property: JsonPropertyName("source")] string? Source = null
);
/// <summary>
/// Bekannte Quellen für Chat-Nachrichten. Wird verwendet um Routing-Entscheidungen zu treffen.
/// </summary>
public static class ChatSource
{
public const string WebView = "webview";
public const string Telegram = "telegram";
public const string AgentComm = "agentcomm";
public const string Job = "job";
}
@@ -0,0 +1,189 @@
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Config;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Engine;
public sealed class ContextCompactor
{
private readonly OpenRouterClient _client;
private readonly ILogger _logger;
private const int ProtectedTailMessages = 6;
private const int MaxToolResultChars = 2000;
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
public ContextCompactor(OpenRouterClient client, ILoggerFactory loggerFactory)
{
_client = client;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
}
public static int EstimateTokens(List<ChatMessage> messages)
{
var totalChars = 0;
foreach (var msg in messages)
{
totalChars += msg.Content?.Length ?? 0;
totalChars += msg.Role.Length + 10;
if (msg.ToolCalls is not null)
{
foreach (var tc in msg.ToolCalls)
totalChars += tc.Function.Name.Length + tc.Function.Arguments.Length + 20;
}
}
return totalChars / 4;
}
public async Task<bool> CompactIfNeededAsync(
List<ChatMessage> messages,
int lastPromptTokens,
LoopGuardConfig guard,
string model,
CancellationToken ct)
{
var contextTokens = lastPromptTokens > 0
? lastPromptTokens
: EstimateTokens(messages);
var threshold = (int)(guard.MaxContextTokens * guard.CompactionThreshold);
if (contextTokens < threshold)
return false;
_logger.LogInformation(
"Context kompaktierung gestartet: {Tokens} Tokens (Schwelle: {Threshold})",
contextTokens, threshold);
// Stufe 1: Tool-Results kürzen
var pruned = PruneToolResults(messages);
if (pruned)
{
var afterPrune = EstimateTokens(messages);
_logger.LogInformation("Stufe 1 (Tool-Pruning): {Before} → {After} geschätzte Tokens",
contextTokens, afterPrune);
if (afterPrune < threshold)
return true;
}
// Stufe 2: Auto-Compaction via LLM
await CompactViaLlmAsync(messages, model, ct);
return true;
}
private bool PruneToolResults(List<ChatMessage> messages)
{
var pruned = false;
var protectedStart = Math.Max(0, messages.Count - ProtectedTailMessages);
for (var i = 0; i < protectedStart; i++)
{
var msg = messages[i];
if (msg.Role != "tool" || msg.Content is null)
continue;
if (msg.Content.Length <= MaxToolResultChars)
continue;
msg.Content = msg.Content[..MaxToolResultChars] + TruncatedMarker;
pruned = true;
}
return pruned;
}
private async Task CompactViaLlmAsync(
List<ChatMessage> messages, string model, CancellationToken ct)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var conversationParts = messages
.Where(m => m.Role != "system")
.Select(FormatMessageForSummary);
var conversationText = string.Join("\n", conversationParts);
// Auf max 30k Zeichen begrenzen für den Summarization-Call
if (conversationText.Length > 30_000)
conversationText = conversationText[..30_000] + "\n[... weitere Nachrichten ausgelassen ...]";
var summaryRequest = new ChatRequest
{
Model = model,
Messages =
[
ChatMessage.System(
"Du bist ein Konversations-Zusammenfasser. Erstelle eine präzise Zusammenfassung " +
"der bisherigen Konversation. Behalte alle wichtigen Fakten, Entscheidungen, " +
"Ergebnisse von Tool-Aufrufen und den aktuellen Arbeitsstand bei. " +
"Schreibe in der dritten Person. Format: Strukturierte Stichpunkte."),
ChatMessage.User(
"Fasse die folgende Konversation zusammen. Behalte alle wichtigen Details:\n\n" +
conversationText)
]
};
try
{
var response = await _client.CompleteAsync(summaryRequest, ct);
var summary = response.Choices.FirstOrDefault()?.Message?.Content;
if (string.IsNullOrWhiteSpace(summary))
{
_logger.LogWarning("Compaction: Keine Zusammenfassung erhalten");
return;
}
// Nachrichten ersetzen: System-Prompt + Zusammenfassung + geschützte letzte Nachrichten
var tail = messages
.Skip(Math.Max(0, messages.Count - ProtectedTailMessages))
.ToList();
messages.Clear();
if (systemMsg is not null)
messages.Add(systemMsg);
messages.Add(ChatMessage.User(
"[Zusammenfassung der bisherigen Konversation]\n\n" + summary));
messages.Add(ChatMessage.Assistant(
"Verstanden. Ich habe den Kontext der bisherigen Konversation erfasst und arbeite weiter."));
messages.AddRange(tail);
_logger.LogInformation(
"Stufe 2 (Auto-Compaction): Konversation auf {Count} Nachrichten kompaktiert",
messages.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Compaction fehlgeschlagen");
}
}
private static string FormatMessageForSummary(ChatMessage msg)
{
if (msg.Role == "tool")
{
var preview = msg.Content?.Length > 200
? msg.Content[..200] + "..."
: msg.Content;
return $"[Tool-Result ({msg.ToolCallId})]: {preview}";
}
if (msg.ToolCalls is { Count: > 0 })
{
var calls = string.Join(", ",
msg.ToolCalls.Select(tc => $"{tc.Function.Name}({tc.Function.Arguments[..Math.Min(100, tc.Function.Arguments.Length)]})"));
return $"[Assistant → Tool-Calls]: {calls}";
}
var content = msg.Content?.Length > 500
? msg.Content[..500] + "..."
: msg.Content;
return $"[{msg.Role}]: {content}";
}
}
+29
View File
@@ -0,0 +1,29 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Engine;
public sealed class LoopGuard
{
private readonly LoopGuardConfig _cfg;
private int _steps;
private int _tokens;
public LoopGuard(LoopGuardConfig cfg) => _cfg = cfg;
public int Steps => _steps;
public int Tokens => _tokens;
public void RecordStep()
{
if (Interlocked.Increment(ref _steps) > _cfg.MaxSteps)
throw new LoopLimitExceededException($"Max steps ({_cfg.MaxSteps}) exceeded.");
}
public void RecordTokens(int count)
{
if (Interlocked.Add(ref _tokens, count) > _cfg.MaxTokens)
throw new LoopLimitExceededException($"Max tokens ({_cfg.MaxTokens}) exceeded.");
}
}
public sealed class LoopLimitExceededException(string message) : Exception(message);
@@ -0,0 +1,118 @@
using System.Collections.Concurrent;
using System.Text;
namespace ClawdDotNet.Core.Logging;
/// <summary>
/// Schreibt Log-Einträge asynchron in datei- und datumsgetrennte Logfiles.
/// Struktur: {LogDirectory}/{Datum}/{Modul}.log
/// Thread-safe durch ConcurrentQueue + dediziertem Writer-Task.
/// </summary>
public sealed class FileLogWriter : IAsyncDisposable
{
private readonly FileLoggerOptions _options;
private readonly ConcurrentQueue<LogEntry> _queue = new();
private readonly SemaphoreSlim _signal = new(0);
private readonly CancellationTokenSource _cts = new();
private readonly Task _writerTask;
private readonly ConcurrentDictionary<string, Lock> _fileLocks = new();
public FileLogWriter(FileLoggerOptions options)
{
_options = options;
Directory.CreateDirectory(_options.LogDirectory);
_writerTask = Task.Run(ProcessQueueAsync);
}
public void Enqueue(LogEntry entry)
{
if (entry.Level < _options.MinimumLevel)
return;
_queue.Enqueue(entry);
_signal.Release();
}
private async Task ProcessQueueAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
await _signal.WaitAsync(_cts.Token);
}
catch (OperationCanceledException)
{
break;
}
DrainQueue();
}
DrainQueue();
}
private void DrainQueue()
{
while (_queue.TryDequeue(out var entry))
{
try
{
WriteEntry(entry);
}
catch
{
// Logging darf die Anwendung niemals crashen
}
}
}
private void WriteEntry(LogEntry entry)
{
var dateDir = Path.Combine(
_options.LogDirectory,
entry.Timestamp.ToString(_options.DateFormat));
Directory.CreateDirectory(dateDir);
var safeModule = SanitizeModuleName(entry.Module);
var filePath = Path.Combine(dateDir, $"{safeModule}.log");
var fileLock = _fileLocks.GetOrAdd(filePath, _ => new Lock());
lock (fileLock)
{
var line = entry.Format(_options.TimestampFormat) + Environment.NewLine;
File.AppendAllText(filePath, line, Encoding.UTF8);
}
}
private static string SanitizeModuleName(string module)
{
var sanitized = module
.Replace('.', '_')
.Replace('/', '_')
.Replace('\\', '_');
foreach (var c in Path.GetInvalidFileNameChars())
sanitized = sanitized.Replace(c, '_');
return string.IsNullOrWhiteSpace(sanitized) ? "Unknown" : sanitized;
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try
{
await _writerTask;
}
catch (OperationCanceledException)
{
}
_signal.Dispose();
_cts.Dispose();
}
}
@@ -0,0 +1,18 @@
namespace ClawdDotNet.Core.Logging;
public sealed class FileLoggerOptions
{
public string LogDirectory { get; set; } = "./Logs";
public LogLevel MinimumLevel { get; set; } = LogLevel.Info;
public int MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; // 10 MB
public string DateFormat { get; set; } = "yyyy-MM-dd";
public string TimestampFormat { get; set; } = "HH:mm:ss.fff";
}
public enum LogLevel
{
Debug = 0,
Info = 1,
Warn = 2,
Error = 3
}
@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using MEL = Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Logging;
/// <summary>
/// ILoggerProvider-Implementierung für das dateibasierte Logging.
/// Erzeugt pro Kategorie (= Modul) einen eigenen ModuleLogger.
///
/// Kategorien werden auf Modulnamen gemappt:
/// "ClawdDotNet.Core.Engine.AgentEngine" → "Core"
/// "ClawdDotNet.Tools.Database.DatabaseTool" → "Tool_Database"
/// "ClawdDotNet.Tools.FileRW.FileRWTool" → "Tool_FileRW"
/// Alles andere → letzter Namespace-Teil oder "General"
/// </summary>
public sealed class FileLoggerProvider : MEL.ILoggerProvider
{
private readonly FileLogWriter _writer;
private readonly FileLoggerOptions _options;
private readonly ConcurrentDictionary<string, ModuleLogger> _loggers = new();
public FileLoggerProvider(FileLoggerOptions options)
{
_options = options;
_writer = new FileLogWriter(options);
}
public MEL.ILogger CreateLogger(string categoryName)
{
var module = MapCategoryToModule(categoryName);
return _loggers.GetOrAdd(module, m => new ModuleLogger(m, _writer, _options));
}
internal static string MapCategoryToModule(string categoryName)
{
if (string.IsNullOrWhiteSpace(categoryName))
return "General";
// ClawdDotNet.Core.* → "Core"
if (categoryName.StartsWith("ClawdDotNet.Core.", StringComparison.Ordinal))
return "Core";
// ClawdDotNet.Host.* → "Host"
if (categoryName.StartsWith("ClawdDotNet.Host.", StringComparison.Ordinal)
|| categoryName == "ClawdDotNet.Host")
return "Host";
// ClawdDotNet.Tools.{ToolName}.* → "Tool_{ToolName}"
if (categoryName.StartsWith("ClawdDotNet.Tools.", StringComparison.Ordinal))
{
var afterTools = categoryName["ClawdDotNet.Tools.".Length..];
var dotIndex = afterTools.IndexOf('.');
var toolName = dotIndex > 0 ? afterTools[..dotIndex] : afterTools;
return $"Tool_{toolName}";
}
// Fallback: letzter Segment-Teil
var lastDot = categoryName.LastIndexOf('.');
return lastDot >= 0 ? categoryName[(lastDot + 1)..] : categoryName;
}
public void Dispose()
{
_writer.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace ClawdDotNet.Core.Logging;
public sealed record LogEntry(
DateTime Timestamp,
LogLevel Level,
string Module,
string Message,
Exception? Exception = null
)
{
public string Format(string timestampFormat)
{
var levelTag = Level switch
{
LogLevel.Debug => "DBG",
LogLevel.Info => "INF",
LogLevel.Warn => "WRN",
LogLevel.Error => "ERR",
_ => "???"
};
var line = $"[{Timestamp.ToString(timestampFormat)}] [{levelTag}] {Message}";
if (Exception is not null)
line += Environment.NewLine + Exception.ToString();
return line;
}
}
@@ -0,0 +1,42 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Logging;
public static class LoggingExtensions
{
public static ILoggerFactory AddClawdFileLogging(
this ILoggerFactory factory,
FileLoggerOptions? options = null)
{
factory.AddProvider(new FileLoggerProvider(options ?? new FileLoggerOptions()));
return factory;
}
public static ILoggerFactory CreateClawdLoggerFactory(
string logDirectory = "./Logs",
LogLevel minimumLevel = LogLevel.Info)
{
var options = new FileLoggerOptions
{
LogDirectory = logDirectory,
MinimumLevel = minimumLevel
};
var factory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(MapToMelLevel(minimumLevel));
});
factory.AddClawdFileLogging(options);
return factory;
}
private static Microsoft.Extensions.Logging.LogLevel MapToMelLevel(LogLevel level) => level switch
{
LogLevel.Debug => Microsoft.Extensions.Logging.LogLevel.Debug,
LogLevel.Info => Microsoft.Extensions.Logging.LogLevel.Information,
LogLevel.Warn => Microsoft.Extensions.Logging.LogLevel.Warning,
LogLevel.Error => Microsoft.Extensions.Logging.LogLevel.Error,
_ => Microsoft.Extensions.Logging.LogLevel.Information
};
}
@@ -0,0 +1,62 @@
using MEL = Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Logging;
/// <summary>
/// Implementiert Microsoft.Extensions.Logging.ILogger und leitet alle
/// Einträge an den zentralen FileLogWriter weiter.
/// Jede Instanz ist einem Modul zugeordnet (z.B. "Core", "Tool_Database").
/// </summary>
public sealed class ModuleLogger : MEL.ILogger
{
private readonly string _module;
private readonly FileLogWriter _writer;
private readonly FileLoggerOptions _options;
public ModuleLogger(string module, FileLogWriter writer, FileLoggerOptions options)
{
_module = module;
_writer = writer;
_options = options;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(MEL.LogLevel logLevel)
{
var mapped = MapLevel(logLevel);
return mapped >= _options.MinimumLevel;
}
public void Log<TState>(
MEL.LogLevel logLevel,
MEL.EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
var message = formatter(state, exception);
var entry = new LogEntry(
DateTime.Now,
MapLevel(logLevel),
_module,
message,
exception);
_writer.Enqueue(entry);
}
private static LogLevel MapLevel(MEL.LogLevel level) => level switch
{
MEL.LogLevel.Trace => LogLevel.Debug,
MEL.LogLevel.Debug => LogLevel.Debug,
MEL.LogLevel.Information => LogLevel.Info,
MEL.LogLevel.Warning => LogLevel.Warn,
MEL.LogLevel.Error => LogLevel.Error,
MEL.LogLevel.Critical => LogLevel.Error,
_ => LogLevel.Info
};
}
@@ -0,0 +1,143 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Scheduling;
public sealed class AgentScheduler : IAsyncDisposable
{
private readonly AgentEngine _engine;
private readonly string _instanceId;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private readonly List<Task> _schedulerTasks = new();
private readonly Dictionary<string, AgentRunResult?> _lastResults = new();
private readonly Lock _resultsLock = new();
public event Action<string, AgentRunResult>? OnRunCompleted;
public AgentScheduler(AgentEngine engine, string instanceId, ILoggerFactory loggerFactory)
{
_engine = engine;
_instanceId = instanceId;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling");
}
public void RegisterAgent(AgentConfig agentConfig)
{
if (agentConfig.Scheduler is null)
return;
_logger.LogInformation("Registering scheduled agent: {AgentId}, cron='{Cron}', runOnStart={RunOnStart}",
agentConfig.AgentId, agentConfig.Scheduler.Cron, agentConfig.Scheduler.RunOnStart);
var task = RunScheduledAgentAsync(agentConfig, _cts.Token);
_schedulerTasks.Add(task);
}
public void RegisterAll(IEnumerable<AgentConfig> agents)
{
foreach (var agent in agents)
RegisterAgent(agent);
}
public async Task<AgentRunResult> RunNowAsync(AgentConfig agentConfig, string userMessage, CancellationToken ct)
{
_logger.LogInformation("Manual run triggered: {AgentId}", agentConfig.AgentId);
var result = await _engine.RunAsync(agentConfig, userMessage, _instanceId, ct);
StoreResult(agentConfig.AgentId, result);
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
return result;
}
public AgentRunResult? GetLastResult(string agentId)
{
lock (_resultsLock)
return _lastResults.GetValueOrDefault(agentId);
}
private async Task RunScheduledAgentAsync(AgentConfig agentConfig, CancellationToken ct)
{
var scheduler = agentConfig.Scheduler!;
if (scheduler.RunOnStart)
{
await ExecuteScheduledRunAsync(agentConfig, ct);
}
if (string.IsNullOrWhiteSpace(scheduler.Cron))
return;
var cron = CronExpression.Parse(scheduler.Cron);
while (!ct.IsCancellationRequested)
{
var now = DateTime.Now;
var next = cron.GetNextOccurrence(now);
if (next is null)
{
_logger.LogWarning("No next occurrence found for agent {AgentId}", agentConfig.AgentId);
return;
}
var delay = next.Value - now;
_logger.LogDebug("Agent {AgentId} next run at {NextRun}", agentConfig.AgentId, next.Value);
try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
break;
}
await ExecuteScheduledRunAsync(agentConfig, ct);
}
}
private async Task ExecuteScheduledRunAsync(AgentConfig agentConfig, CancellationToken ct)
{
try
{
var result = await _engine.RunAsync(
agentConfig,
agentConfig.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.",
_instanceId,
ct);
StoreResult(agentConfig.AgentId, result);
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
_logger.LogInformation(
"Scheduled run completed: {AgentId}, status={Status}, tokens={Tokens}",
agentConfig.AgentId, result.Status, result.TokensUsed);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Scheduled run failed for {AgentId}", agentConfig.AgentId);
}
}
private void StoreResult(string agentId, AgentRunResult result)
{
lock (_resultsLock)
_lastResults[agentId] = result;
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try
{
await Task.WhenAll(_schedulerTasks);
}
catch (OperationCanceledException)
{
}
_cts.Dispose();
}
}
@@ -0,0 +1,119 @@
namespace ClawdDotNet.Core.Scheduling;
/// <summary>
/// Einfaches Cron-Parsing für 5-Felder-Ausdrücke: Minute Stunde Tag Monat Wochentag
/// Unterstützt: Zahlen, Wildcards (*), Bereiche (1-5), Listen (1,3,5), Schritte (*/5)
/// </summary>
public sealed class CronExpression
{
private readonly HashSet<int> _minutes;
private readonly HashSet<int> _hours;
private readonly HashSet<int> _daysOfMonth;
private readonly HashSet<int> _months;
private readonly HashSet<int> _daysOfWeek;
private CronExpression(
HashSet<int> minutes, HashSet<int> hours,
HashSet<int> daysOfMonth, HashSet<int> months,
HashSet<int> daysOfWeek)
{
_minutes = minutes;
_hours = hours;
_daysOfMonth = daysOfMonth;
_months = months;
_daysOfWeek = daysOfWeek;
}
public static CronExpression Parse(string expression)
{
var parts = expression.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 5)
throw new FormatException($"Cron expression must have 5 fields, got {parts.Length}: '{expression}'");
return new CronExpression(
ParseField(parts[0], 0, 59),
ParseField(parts[1], 0, 23),
ParseField(parts[2], 1, 31),
ParseField(parts[3], 1, 12),
ParseField(parts[4], 0, 6)
);
}
public bool Matches(DateTime dt)
{
return _minutes.Contains(dt.Minute)
&& _hours.Contains(dt.Hour)
&& _daysOfMonth.Contains(dt.Day)
&& _months.Contains(dt.Month)
&& _daysOfWeek.Contains((int)dt.DayOfWeek);
}
public DateTime? GetNextOccurrence(DateTime after)
{
var candidate = new DateTime(after.Year, after.Month, after.Day, after.Hour, after.Minute, 0)
.AddMinutes(1);
// Suche maximal 2 Jahre in die Zukunft
var limit = after.AddYears(2);
while (candidate < limit)
{
if (Matches(candidate))
return candidate;
candidate = candidate.AddMinutes(1);
// Optimierung: überspringe ungültige Stunden/Tage
if (!_months.Contains(candidate.Month))
{
candidate = new DateTime(candidate.Year, candidate.Month, 1).AddMonths(1);
continue;
}
if (!_daysOfMonth.Contains(candidate.Day) || !_daysOfWeek.Contains((int)candidate.DayOfWeek))
{
candidate = new DateTime(candidate.Year, candidate.Month, candidate.Day).AddDays(1);
continue;
}
if (!_hours.Contains(candidate.Hour))
{
candidate = new DateTime(candidate.Year, candidate.Month, candidate.Day, candidate.Hour, 0, 0)
.AddHours(1);
}
}
return null;
}
private static HashSet<int> ParseField(string field, int min, int max)
{
var result = new HashSet<int>();
foreach (var part in field.Split(','))
{
if (part == "*")
{
for (var i = min; i <= max; i++) result.Add(i);
}
else if (part.Contains('/'))
{
var split = part.Split('/');
var start = split[0] == "*" ? min : int.Parse(split[0]);
var step = int.Parse(split[1]);
for (var i = start; i <= max; i += step) result.Add(i);
}
else if (part.Contains('-'))
{
var split = part.Split('-');
var from = int.Parse(split[0]);
var to = int.Parse(split[1]);
for (var i = from; i <= to; i++) result.Add(i);
}
else
{
result.Add(int.Parse(part));
}
}
return result;
}
}

Some files were not shown because too many files have changed in this diff Show More