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>
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: "1K–15K" | "15K–50K" | 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.**
|
||||
@@ -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.**
|
||||
@@ -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 1–2):
|
||||
|
||||
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.**
|
||||
@@ -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.**
|
||||
@@ -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`""
|
||||
@@ -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("BuildDate", "$(_BuildTimestamp)")]"
|
||||
Overwrite="true"
|
||||
WriteOnlyWhenDifferent="false" />
|
||||
<ItemGroup>
|
||||
<Compile Include="$(_BuildTimestampFile)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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; }
|
||||
@@ -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">▶</button>
|
||||
<button id="btn-abort" title="Abbrechen" style="display:none;">■</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>
|
||||
@@ -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 ''; }
|
||||
}
|
||||
})();
|
||||
@@ -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; }
|
||||
@@ -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;">↗</button>
|
||||
<button id="btn-run-now" title="Jetzt ausführen" style="display:none;">▶</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>
|
||||
@@ -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 ''; }
|
||||
}
|
||||
})();
|
||||
@@ -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?
|
||||
|
After Width: | Height: | Size: 173 KiB |
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; } = "—";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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(' ', '_');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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? | 1–5 (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) | 2–3 Agenten |
|
||||
| Komplexes System mit Koordination | 3–5 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.15–0.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 | 5–10 | 20.000 | 120 |
|
||||
| Standard-Aufgabe | 10–20 | 50.000 | 300 |
|
||||
| Komplexe Analyse | 20–30 | 80.000 | 600 |
|
||||
| Budget-kritisch | 3–5 | 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
|
||||
@@ -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`
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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})";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||