Fruehjahrsputz: WinForms-Altlast entfernt, Dokumentation nachgezogen
Die Avalonia-Portierung ist abgeschlossen, damit ist die in ClawdDotNet.slnx angekuendigte Aufgabe "WinForms-Oberflaeche entfernen" faellig. Der Stand davor liegt unter dem Tag vor-fruehjahrsputz-2026-08. Entfernt (56 Dateien, seit dem Herausloesen der Anwendungsschicht nicht mehr Teil des Builds): ClawdDotNet.csproj, Program.cs, sieben frm_*-Formulare, UI/, Models/, EmbeddedUI/, Properties/, Resources/, Services/, das alte Anwendungssymbol und Deploy-Build.ps1 (ersetzt durch deploy/publish.py). Dazu configs/*.json - Beispielkonfigurationen aus der Zeit vor dem Instanzverzeichnis, auf die nur noch die alten Prompts verwiesen. Die vier Entwicklungs-Prompts der Anfangszeit ziehen nach docs/archiv/ um, mit README, das ihren Stand einordnet. Eine Regel darin gilt weiter - die Pflichtfelder fetchedAt/dataAsOf/source der Internet-Tools -, deshalb Archiv statt Loeschen; der WebSearch-Plan verweist auf den neuen Pfad. Toter Code - PlaceholderPageViewModel samt Ansicht: Es gibt keinen Platzhalter-Bereich mehr, seit alle neun Seiten portiert sind. - Snappier als direkter Paketverweis: MongoDB.Driver loest es ohnehin auf dieselbe Fassung auf, der Verweis hob nichts an. Zwei Fehler, die dabei sichtbar wurden - Die taegliche Sicherung lief ins Leere. Die Oberflaeche bot sie an und schrieb Uhrzeit, Zielordner und Anzahl in die Einstellungen, aber der BackupScheduler wurde nirgends erzeugt. Jetzt am AppHost verdrahtet und in den geordneten Abbau aufgenommen. - SettingsPageViewModel hielt die vier Sicherungs-Einstellungen doppelt. Aus der Ansicht waren sie laengst verschwunden, gelesen und beim Speichern zurueckgeschrieben wurden sie weiter: Wer die Uhrzeit auf der Sicherungs-Seite aenderte und danach die Einstellungen speicherte, bekam den alten Wert zurueck. Pakete: keine bekannten Sicherheitsluecken mehr - SQLitePCLRaw.bundle_e_sqlite3 auf 2.1.13 angehoben. Microsoft.Data.Sqlite bringt 2.1.11 mit, darin steckt GHSA-2m69-gcr7-jv3q (NU1903, hoch). - SharpCompress bleibt als direkter Verweis stehen. Beim Aufraeumen erst als ungenutzt entfernt - dabei kam die von MongoDB.Driver gezogene Fassung 0.30.1 mit GHSA-6c8g-7p36-r338 zurueck. Der Verweis ist eine Anhebung, kein Ballast; das steht jetzt als Kommentar dabei. Dokumentation - Roadmap mit Statusblock: A1 und A3 erledigt, A2 nur zur Haelfte - Gate, Policy und Dienst greifen, aber keine Ansicht ruft ApproveAsync auf, ein gestagter Aufruf liegt unbeantwortet. Das ist jetzt Punkt 1 der Reihung. Rocket.Chat steht und kollidiert mit A5 (Matrix) - Entscheidung faellig. - Avalonia-Portierungsleitfaden -> Oberflaechen-Leitfaden: kein Auftrag mehr, sondern Beschreibung des Stands. - Bestandsaufnahme und Linux-Analyse als datierte Befunde gekennzeichnet; der teure Teil der Linux-Analyse (8.900 Zeilen WinForms) ist hinfaellig. - Verweise auf frm_*, WebView2 und ClawdDotNet.csproj in den lebenden Dokumenten richtiggestellt. Build fehlerfrei, 585 Tests gruen (6 uebersprungen). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -222,9 +222,11 @@ zu sehen, mit wem er überhaupt spricht. Zeitraum, Kanalfilter, Freitextsuche.
|
||||
trägt eine kleine Kennzeichnung des Kanals (`Rückfrage` / `Auftrag` / `Kommentar` /
|
||||
`Ergebnis`) und, wo vorhanden, die anklickbare Task-Id.
|
||||
|
||||
**Technisch**: über den vorhandenen **WebView2**-Unterbau, wie ihn `frm_chat` schon nutzt
|
||||
**Technisch**: damals über den WebView2-Unterbau von `frm_chat` gedacht. Beides gibt es
|
||||
seit der Avalonia-Portierung nicht mehr — der Abschnitt ist als Entwurfsstand von damals
|
||||
zu lesen; die Ansicht wäre heute eine Avalonia-Seite wie `AgentChatsPageView`
|
||||
(inkl. Virtual-Host-Mapping auf einen lokalen Ordner). Der Verlauf wird als HTML
|
||||
gerendert. Handgezeichnete Sprechblasen in WinForms wären ein Vielfaches an Aufwand für
|
||||
gerendert. Handgezeichnete Sprechblasen wären dort ein Vielfaches an Aufwand für
|
||||
ein schlechteres Ergebnis.
|
||||
|
||||
**Paging**: die jüngsten ~200 Nachrichten, „ältere laden" nach oben. Ein Paar mit 50.000
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
Vollständiges Review von Core-Engine, Security-Layer, Tools, Scheduling und UI.
|
||||
Stand: Commit `92e50d3`.
|
||||
|
||||
> **Dies ist ein datierter Befund, keine Todoliste.** Er wird nicht fortgeschrieben.
|
||||
> Was davon offen ist, steht in der [Roadmap](Roadmap.md) — dort auch, was seither
|
||||
> erledigt wurde. Die Abschnitte zur Oberfläche beziehen sich auf die Windows-Forms-
|
||||
> Fassung, die es seit dem 2026-08-23 nicht mehr gibt.
|
||||
|
||||
**Kurzfassung:** Die Architektur ist gut — die Trennung Core/Tools/UI, das `IAgentTool`-Interface,
|
||||
der Tool-Job-Mechanismus und das Instanz-Konzept tragen. Die Probleme liegen fast alle in
|
||||
drei Bereichen: (1) Sicherheitsprüfungen, die als String-Vergleiche implementiert sind und
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
Stand: 2026-08-06. Reine Bestandsaufnahme und Aufwandsschätzung, **kein** Umbau.
|
||||
|
||||
> **Nachtrag 2026-08-23:** Der teure Teil dieser Analyse hat sich erledigt. Die rund
|
||||
> 8.900 Zeilen Windows-Forms-Oberfläche samt WebView2 und den vier `PropertyGrid`-
|
||||
> Instanzen gibt es nicht mehr — sie sind durch `src/ClawdDotNet.Desktop` (Avalonia,
|
||||
> `net10.0`) ersetzt und beim Frühjahrsputz entfernt worden. Damit fällt der zweite
|
||||
> der beiden unten vorgeschlagenen Schnitte weg: Es gibt keine Windows-gebundene
|
||||
> Oberfläche mehr, die noch umzuziehen wäre. Offen bleiben die drei Kernstellen
|
||||
> (DPAPI, Pfadvergleiche, Zeitzonen-IDs) und ein `linux-x64`-Release (Roadmap DC3).
|
||||
|
||||
Frage: Was ist nötig, damit ClawdDotNet unter Linux läuft, und was kostet das?
|
||||
|
||||
---
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
# Avalonia-Portierung — Leitfaden
|
||||
# Oberfläche — Leitfaden
|
||||
|
||||
Für alle, die weitere Ansichten von WinForms nach Avalonia übertragen.
|
||||
Stand: 2026-08-07.
|
||||
Für alle, die an `src/ClawdDotNet.Desktop` arbeiten.
|
||||
Stand: 2026-08-23.
|
||||
|
||||
---
|
||||
|
||||
## 1. Auftrag
|
||||
## 1. Stand
|
||||
|
||||
Drei Bereiche des Hauptfensters sind noch Platzhalter. In dieser Reihenfolge portieren —
|
||||
sie steigen im Umfang, und jede baut auf dem Muster der vorigen auf:
|
||||
**Die Portierung ist abgeschlossen.** Es gibt keine Platzhalter mehr und keine
|
||||
WinForms-Vorlage, gegen die man vergleichen könnte: Die alte Oberfläche
|
||||
(`ClawdDotNet.csproj`, `frm_*.cs`, `UI/`, `Models/`, `EmbeddedUI/`) ist am
|
||||
2026-08-23 aus dem Arbeitsbaum entfernt worden. Wer sie doch einmal braucht,
|
||||
findet sie im Tag `vor-fruehjahrsputz-2026-08`.
|
||||
|
||||
| # | Bereich | WinForms-Vorlage | Daten aus |
|
||||
|---|---|---|---|
|
||||
| 1 | **Info** | `frm_main.Designer.cs`, Suchwort `tabPage_Info` | `AppHost.AppVersion`, `AppHost.BuildSummary`, `host.Instance` |
|
||||
| 2 | **Sicherung** | `UI/BackupPanel.cs` + `UI/BackupPanel.Designer.cs` | `host.InstancePath`, `host.Settings`, `Core.Backup.BackupService` |
|
||||
| 3 | **Aufgaben** (Jobs/Services/Verlauf) | `frm_main.cs`, Abschnitt `WORKER TAB` ab Zeile 932 | `host.Instance.Agents`, `App.Services.JobHistoryService` |
|
||||
Neun Bereiche, alle nativ in Avalonia:
|
||||
|
||||
**Nicht anfassen:** Chat und Einstellungen. Beide sind Entwurfsarbeit, nicht Übersetzung,
|
||||
und werden gesondert gemacht.
|
||||
| Gruppe | Bereich | Ansichtsmodell |
|
||||
|---|---|---|
|
||||
| Arbeit | Chat, Agenten, Aufgaben | `ChatPageViewModel`, `AgentsPageViewModel`, `TasksPageViewModel` |
|
||||
| Analyse | Token-Verbrauch, Agenten-Chats | `TokenUsagePageViewModel`, `AgentChatsPageViewModel` |
|
||||
| System | Sicherung, Protokoll, Einstellungen, Info | `BackupPageViewModel`, `LogPageViewModel`, `SettingsPageViewModel`, `InfoPageViewModel` |
|
||||
|
||||
Die WinForms-Dateien liegen noch im Repository, sind aber **nicht mehr Teil des Builds**
|
||||
(siehe Kommentar in `ClawdDotNet.slnx`). Sie sind Vorlage zum Lesen — nicht zum Kompilieren,
|
||||
nicht zum Reparieren.
|
||||
Dazu die Fenster `InstancePickerWindow`, `LicenseWindow`, `TextEditorWindow` und
|
||||
die Dialoge zum Anlegen von Agent, Auftrag und Dienst.
|
||||
|
||||
Das Erscheinungsbild folgt dem Entwurf in `Mockup/` — wer daran etwas ändert,
|
||||
liest zuerst `Mockup/extracted/mockup/Implementierungsleitfaden.md`.
|
||||
|
||||
**Was in der Oberfläche noch fehlt**, siehe [Roadmap](Roadmap.md):
|
||||
die Freigabe-Ansicht für gestagte Aufrufe (A2) und die Schaltfläche
|
||||
„Fehler melden" (DC1).
|
||||
|
||||
---
|
||||
|
||||
+58
-11
@@ -1,5 +1,22 @@
|
||||
# Roadmap
|
||||
|
||||
**Stand: 2026-08-23** (Frühjahrsputz). Was seither gilt:
|
||||
|
||||
| Bereich | Stand |
|
||||
|---|---|
|
||||
| A1 Taskboard | **erledigt** — Dateiformat, Scanner mit Claiming, `task_*`-Tool, Migration, Tests |
|
||||
| A3 Audit-Log | **erledigt** — append-only auf SQLite, Engine stempelt die Herkunft |
|
||||
| A2 Staging | **halb** — Gate, Policy, Ablage und Dienst stehen und greifen; **die Freigabe-Ansicht fehlt.** Bis sie da ist, legt ein Agent Vorschläge ab, die niemand freigeben kann |
|
||||
| Oberfläche | **portiert** — neun Bereiche nativ in Avalonia, Entwurf aus `Mockup/` umgesetzt. Die WinForms-Fassung ist entfernt (Tag `vor-fruehjahrsputz-2026-08`) |
|
||||
| Rocket.Chat | Tool umgesetzt und registriert. **Damit steht A5 zur Entscheidung an** — siehe unten |
|
||||
| Pakete | keine bekannten Sicherheitslücken mehr; `SharpCompress` und `SQLitePCLRaw.bundle_e_sqlite3` sind als Anhebung direkt verwiesen |
|
||||
|
||||
Die vier [Umsetzungspläne](umsetzungsplaene/) vom 2026-08-05 sind **alle noch offen**:
|
||||
FileRW-Papierkorb, AgentInspector, AgentEditor-Härtung, WebSearch. Ihre Reihenfolge
|
||||
steht in den Dokumenten selbst; sie sind hier nicht doppelt eingeordnet.
|
||||
|
||||
---
|
||||
|
||||
Zentrale Liste aller offenen Vorhaben. Sie löst die beiden „Vorgeschlagene
|
||||
Reihenfolge"-Abschnitte in der [Bestandsaufnahme](Bestandsaufnahme-2026-07.md) und im
|
||||
[Konzepte-Dokument](Konzepte-Backup-Finanz-Analyse.md) ab — die bleiben als Befund bzw.
|
||||
@@ -16,7 +33,7 @@ Hintergrund: Konzeptvergleich mit [OpenAlice](https://github.com/TraderAlice/Ope
|
||||
Staging-Freigabe, Audit-Log und das Skill-Modell. Die Inbox-Idee entfällt zugunsten
|
||||
der geplanten Matrix-Migration (A5).
|
||||
|
||||
### A1 — Taskboard
|
||||
### A1 — Taskboard — **erledigt**
|
||||
|
||||
Aufgaben als Markdown-Dateien mit YAML-Frontmatter im `SharedWorkspace`:
|
||||
`title`, `status` (`backlog | todo | in_progress | done | canceled`), `priority`,
|
||||
@@ -73,7 +90,13 @@ Frontmatter, nicht in einen eigenen Mechanismus.
|
||||
Konzept-Doc: [Taskboard-Konzept](Taskboard-Konzept.md) (Dateiformat,
|
||||
Wahrheitsaufteilung Datei/DB, Scanner-Verhalten, Invarianten, Migration).
|
||||
|
||||
### A2 — Staging-Freigabe für irreversible Aktionen (F-A1 + S4)
|
||||
### A2 — Staging-Freigabe für irreversible Aktionen (F-A1 + S4) — **Kern erledigt, Ansicht offen**
|
||||
|
||||
> **Der offene Rest ist die Freigabe-Ansicht.** `StagingService` bietet
|
||||
> `ListPendingAsync`, `ApproveAsync` und `RejectAsync`, `AppHost.Staging` reicht ihn
|
||||
> an die Oberfläche durch — aber kein Ansichtsmodell ruft sie auf. Ein gestagter
|
||||
> Aufruf liegt damit unbegrenzt in der Ablage. Solange das so ist, ist jede Aktion
|
||||
> auf `Approve` faktisch eine Aktion auf `Deny`, nur ohne Rückmeldung an den Agenten.
|
||||
|
||||
Konzept-Doc: [Staging-Konzept](Staging-Konzept.md).
|
||||
|
||||
@@ -98,7 +121,7 @@ Ergänzungen (Juli 2026 beschlossen):
|
||||
Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen Vorschlag
|
||||
erzeugen, keine Ausführung.
|
||||
|
||||
### A3 — Audit-Log (F-A2)
|
||||
### A3 — Audit-Log (F-A2) — **erledigt**
|
||||
|
||||
Konzept-Doc: [Audit-Konzept](Audit-Konzept.md).
|
||||
|
||||
@@ -157,6 +180,13 @@ Agenten) wird auf Element/Matrix umgestellt.
|
||||
Scope ist noch unbestimmt — braucht ein eigenes Konzept-Doc, bevor es in die
|
||||
Reihenfolge eingeordnet wird.
|
||||
|
||||
**Kollision, seit Rocket.Chat steht (August 2026):** Das
|
||||
[Rocket.Chat-Tool](../src/ClawdDotNet.Tools.RocketChat/) ist umgesetzt und im
|
||||
`AppHost` registriert — es bedient genau den Zweck, für den A5 Matrix vorsah.
|
||||
Entweder A5 entfällt und Rocket.Chat wird der Weg, oder wir betreiben zwei
|
||||
Chat-Wege nebeneinander. Das ist **die nächste offene Grundsatzentscheidung**;
|
||||
siehe [RocketChat-Nextcloud-Konzept](RocketChat-Nextcloud-Konzept.md).
|
||||
|
||||
### A6 — MySQL-Replikations-Spiegel (optional)
|
||||
|
||||
Beschlossen Juli 2026. **SQLite bleibt die einzige Wahrheit** — gearbeitet wird
|
||||
@@ -203,10 +233,19 @@ Spiegel auch die Historie. Zeitlich passt A6 zu dem Server, der ggf. mit A5
|
||||
| Memory-Flush vor Compaction | Bevor der ContextCompactor zusammenfasst, bekommt der Agent ein eng begrenztes Fenster, Dauerhaftes per `memory_store` zu sichern — sonst wirft die Compaction Wissen weg | beschlossen (GoClaw-Muster) |
|
||||
| Memory-Auto-Injection | Relevante Memory-Abstracts werden automatisch eingeblendet (Relevanzschwelle, Deckel ~200 Tokens), **in die Nutzernachricht, nie in den System-Prompt** (Prompt-Cache T1) | beschlossen; löst den offenen Punkt „Automatische Einblendung" im [Memory-Konzept](Memory-Konzept.md) |
|
||||
| Output-Scrubbing | Bekannte Secret-Werte (Register des `SecretProtector`) werden zentral aus **allen** Tool-Ergebnissen maskiert, bevor sie in Kontext, Historie oder Spiegel (A6) gelangen | beschlossen; schließt die Lücke, die S3 nur für URLs schloss |
|
||||
| Hygiene-Paket | B9 (`index_Count`-Race), B11/T8 (`max_tokens` setzen), B13 (`instanceId`-Inkonsistenz), F-A6-Rest (UI zum Setzen/Rotieren der Secrets) | Kleinbugs, in einem Aufwasch. B10 geht im Historie-Umzug (A6) auf |
|
||||
| Hygiene-Paket | B9 (`index_Count`-Race), B13 (`instanceId`-Inkonsistenz), F-A6-Rest (UI zum Setzen/Rotieren der Secrets) | Kleinbugs, in einem Aufwasch. B11/T8 (`max_tokens`) ist erledigt — `AgentEngine` setzt es aus `LoopGuard.MaxResponseTokens`. B10 geht im Historie-Umzug (A6) auf |
|
||||
|
||||
Erledigt seit der letzten Fortschreibung: B4 vollständig (Preise kommen live vom
|
||||
`/models`-Endpunkt, unbekannte Modelle werden sichtbar gemeldet).
|
||||
`/models`-Endpunkt, unbekannte Modelle werden sichtbar gemeldet). Beim Frühjahrsputz
|
||||
dazugekommen: die automatische Sicherung lief ins Leere — die Oberfläche bot sie an
|
||||
und schrieb die Uhrzeit weg, aber der `BackupScheduler` wurde nie gestartet. Jetzt
|
||||
verdrahtet.
|
||||
|
||||
Neu aufgenommen (aus der Ideensammlung, die dabei aufgelöst wurde):
|
||||
|
||||
| Punkt | Was |
|
||||
|---|---|
|
||||
| Werkzeugwunsch | Agenten bekommen in den Kern-Prompt den Hinweis, sich zu melden, wenn ihnen ein Werkzeug fehlt, das es noch nicht gibt. Der Weg dafür ist da — ein Task an `@human` (A1). Gehört zum Prompt-Kern von A4 |
|
||||
|
||||
### B-DC — Deploymentcenter-Anbindung
|
||||
|
||||
@@ -221,7 +260,7 @@ Offen:
|
||||
|---|---|---|
|
||||
| DC1 | Oberfläche „Fehler melden" | Client vorhanden, Schaltfläche fehlt |
|
||||
| DC2 | Agenten-Tool für den Bugtracker | macht den Claim/Lease-Workflow des Deploymentcenters nutzbar |
|
||||
| DC3 | Release-Strecke | **erledigt für win-x64/dev** ([`deploy/publish.py`](../deploy/publish.py), 0.1.1 veröffentlicht). Offen: `linux-x64` nach der Avalonia-Portierung, `prod` |
|
||||
| DC3 | Release-Strecke | **erledigt für win-x64/dev** ([`deploy/publish.py`](../deploy/publish.py), 0.1.1 veröffentlicht). Offen: `linux-x64` — die Portierung, auf die das wartete, ist durch — und `prod` |
|
||||
| DC4 | SDK als Git-Submodul unter `external/` statt Cross-Repo-Pfad | betrifft auch die CI |
|
||||
| DC5 | Betreiber: Evaluator-Cron einrichten, Token ausstellen, `parent_source` pflegen | **ohne den Cron ist die Überwachung wertlos** |
|
||||
| DC6 | Update anwenden statt nur melden | **erledigt.** Agent liegt im Paket (Prüfsumme geprüft), Rückfrage in der Oberfläche, geordnetes Herunterfahren vor dem Agentenstart, `maintenance` an den Watchdog |
|
||||
@@ -262,17 +301,20 @@ Notify ist gestrichen — geht in A5 auf.
|
||||
|
||||
| # | Vorhaben | Begründung |
|
||||
|---|---|---|
|
||||
| 1 | A1 Taskboard | Fundament; löst sechs bestehende Punkte auf einmal |
|
||||
| 2 | A3 Audit-Log | klein, sofort nützlich; muss vor A2 da sein, damit Freigaben protokolliert werden |
|
||||
| 3 | A2 Staging-Freigabe | größter Sicherheitsgewinn; Voraussetzung für unbeaufsichtigten Betrieb |
|
||||
| ✅ | A1 Taskboard | Fundament; löste sechs bestehende Punkte auf einmal |
|
||||
| ✅ | A3 Audit-Log | klein, sofort nützlich; musste vor A2 da sein, damit Freigaben protokolliert werden |
|
||||
| **1** | **A2 — Freigabe-Ansicht** | Der Rest von A2 und das einzige Stück, das jetzt wirklich blockiert: Ohne sie liegen gestagte Aufrufe unbeantwortet. Kleiner Umfang, große Wirkung |
|
||||
| 2 | FileRW-Papierkorb | [eigener Plan](umsetzungsplaene/UMSETZUNGSPLAN-FileRW-Papierkorb-Cleanup.md); kleinster Eingriff, entschärft ein reales Risiko und erlaubt, `FileRW.delete` von `Approve` auf `Auto` zu senken |
|
||||
| 3 | A5 entscheiden: Matrix oder Rocket.Chat | Blockiert K4 (Streaming) und färbt auf A6 ab. Entscheidung, keine Umsetzung — deshalb früh |
|
||||
| 4 | C1 Marktkalender | spart sofort Kosten; nutzt A1-Frontmatter |
|
||||
| 5 | A4 Skills/Toolsets | Token-Hebel, Cache-stabil |
|
||||
| 6 | C2 Indicators | Qualität + Kosten |
|
||||
| 7 | C7 + C8 Ergebnisregister, Aussagen | das eigentliche Leistungsmaß; braucht A3 |
|
||||
| — | DC1 „Fehler melden"-Schaltfläche | zwischendurch; der Client steht, es fehlt der Knopf |
|
||||
| — | Hygiene-Paket (B) | zwischendurch, unabhängig |
|
||||
| — | Historie-Umzug in die Instanz-DB | löst B10 + K6; Voraussetzung für A6 |
|
||||
| — | A6 MySQL-Spiegel | nach dem Historie-Umzug; natürliches Zuhause auf dem A5-Server |
|
||||
| — | A5 Matrix | eigenes Konzept-Doc zuerst; Scope klären, dann einordnen |
|
||||
| — | A5 umsetzen | erst nach der Entscheidung aus Zeile 3; der gewählte Weg bekommt dann sein Konzept-Doc |
|
||||
|
||||
Leitlinie der Reihung: erst Nachvollziehbarkeit und Kontrolle (Audit, Staging),
|
||||
dann Fähigkeiten — ein Agent, der unbeaufsichtigt läuft, braucht zuerst Bremsen,
|
||||
@@ -283,7 +325,12 @@ dann PS.
|
||||
## Umsetzung mit Opus 4.6 — Einstufung
|
||||
|
||||
Die Entwicklung erfolgt mit Opus 4.6. Die meisten Vorhaben sind damit gut
|
||||
machbar, sofern die hier notierten Vorgaben mitgegeben werden. Zwei Stellen
|
||||
machbar, sofern die hier notierten Vorgaben mitgegeben werden.
|
||||
|
||||
> Die beiden hier markierten Stellen sind inzwischen gebaut: Der **Scanner-Kern**
|
||||
> (A1) steht samt Claiming und Reconciliation, die **Fortsetzung nach Freigabe**
|
||||
> (A2) folgt der Vorgabe unten — der Lauf endet beim Staging regulär, kein Suspend.
|
||||
> Die Tabelle bleibt als Begründung stehen, warum es so gebaut wurde. Zwei Stellen
|
||||
berühren Nebenläufigkeits-Invarianten bzw. Engine-Querschnitte — sie sind für
|
||||
Opus 5 / Fable markiert oder durch eine Architektur-Vorgabe entschärft.
|
||||
|
||||
|
||||
@@ -6,7 +6,14 @@ Der typische Ablauf ist die Kombination aus beidem — „schreib mir die Auswer
|
||||
sie in die Cloud" im Chat, Datei in Nextcloud, Link zurück in den Chat.
|
||||
|
||||
Dieses Dokument prüft die Machbarkeit, legt den Schnitt fest und benennt die Punkte, die
|
||||
vor der Umsetzung entschieden werden müssen. **Es ist noch keine Umsetzungsfreigabe.**
|
||||
vor der Umsetzung entschieden werden müssen.
|
||||
|
||||
> **Stand 2026-08-23:** Der Rocket.Chat-Teil ist **umgesetzt** —
|
||||
> `src/ClawdDotNet.Tools.RocketChat`, im `AppHost` registriert, `send_file`
|
||||
> freigabepflichtig. Der Befund unten bleibt als Begründung stehen; Abschnitt 2.0
|
||||
> hält fest, was die Messung gegen die echte Instanz an den Annahmen korrigiert hat.
|
||||
> **Offen:** der Nextcloud-Teil und die Kollision mit Roadmap A5 (Matrix) — die ist
|
||||
> eine Entscheidung, keine Umsetzung.
|
||||
|
||||
Verwandt: [Taskboard-Konzept](Taskboard-Konzept.md) (Scanner/Wake), [Staging-Konzept](Staging-Konzept.md)
|
||||
(Freigaben), [Audit-Konzept](Audit-Konzept.md), [Roadmap](Roadmap.md) (A5 — siehe Konflikt unten).
|
||||
@@ -177,7 +184,7 @@ der richtige Ansatz und wird von Rocket.Chat direkt unterstützt.
|
||||
- Authentifiziert wird jeder Aufruf über zwei Header: `X-Auth-Token` und `X-User-Id`.
|
||||
|
||||
**Entscheidung, die ich empfehle:** Das Anlegen der Benutzer ist **kein Agenten-Tool**.
|
||||
Es ist eine einmalige Einrichtungsfunktion in der WinForms-Oberfläche
|
||||
Es ist eine einmalige Einrichtungsfunktion in der Oberfläche
|
||||
(Instanz-Einstellungen → Rocket.Chat → „Agenten-Benutzer anlegen"). Sonst müsste ein
|
||||
Agent ein Admin-Token halten — und ein Admin-Token in Reichweite einer Prompt-Injection
|
||||
ist genau das, was A2 verhindern soll. Der Admin-Token liegt in der **Instanz**-Konfiguration,
|
||||
@@ -444,7 +451,7 @@ Das ist die Anforderung, die die Architektur bestimmt — nicht der Chat selbst.
|
||||
|
||||
| Kanal | Unabhängig von Rocket.Chat? | Richtung |
|
||||
|---|---|---|
|
||||
| WinForms-Chat (`frm_chat`) | vollständig — läuft in der App selbst | beide |
|
||||
| Chat-Seite in der App (`ChatPageView`) | vollständig — läuft in der App selbst | beide |
|
||||
| Telegram-Bot-Tool | ja — fremde Infrastruktur | beide |
|
||||
| Mail-Tool | ja, sofern der Mailserver anderswo läuft | beide |
|
||||
| Web-Chat / `ClawdDotNetApi` | ja, aber nur im lokalen Netz | beide |
|
||||
@@ -483,7 +490,7 @@ Ein Tool-Job `rocketchat_health` (Takt ~5 Minuten, `GET /api/info`):
|
||||
|
||||
**Eingehend während des Ausfalls:** Der Telegram-Poll-Job bleibt dauerhaft aktiv, nur mit
|
||||
langsamem Takt (z. B. alle 5 Minuten). Er kostet nichts, wenn nichts kommt — und ist im
|
||||
Ernstfall der Weg, auf dem *du* die Agenten erreichst. Der WinForms-Chat ist ohnehin immer
|
||||
Ernstfall der Weg, auf dem *du* die Agenten erreichst. Die eingebaute Chat-Seite ist ohnehin immer
|
||||
da, solange die App läuft.
|
||||
|
||||
**Entschieden (August 2026): Telegram ist der Notfallkanal.** Die Kanalliste lautet damit
|
||||
@@ -706,9 +713,9 @@ Damit der Zuschnitt klar ist:
|
||||
|
||||
- Keine Rocket.Chat-**App** (Apps-Engine, TypeScript im Server) — wir bleiben Client.
|
||||
- Keine Verwaltung von Rocket.Chat durch Agenten (Benutzer anlegen, Räume erstellen,
|
||||
Rechte vergeben). Das ist Admin-Arbeit in der WinForms-Oberfläche.
|
||||
Rechte vergeben). Das ist Admin-Arbeit in der Oberfläche.
|
||||
- Keine Sprach-/Videofunktionen, keine Nextcloud Talk-Anbindung.
|
||||
- Kein Ersatz für den WinForms-Chat — der bleibt und ist die unterste Rückfallebene.
|
||||
- Kein Ersatz für die eingebaute Chat-Seite — die bleibt und ist die unterste Rückfallebene.
|
||||
- Keine Ende-zu-Ende-Verschlüsselung. Rocket.Chat kann das, aber verschlüsselte Räume sind
|
||||
über die REST-API nicht lesbar. Agenten arbeiten in unverschlüsselten Räumen — das ist
|
||||
eine bewusste Einschränkung, die du kennen solltest.
|
||||
|
||||
@@ -102,7 +102,8 @@ Erst nach gewonnenem Übergang wird der eingefrorene Aufruf ausgeführt.
|
||||
## Offen
|
||||
|
||||
- **Review-Oberfläche** im Hauptfenster (Liste der offenen Vorschläge, Freigeben/Ablehnen)
|
||||
— die Dienst-API steht bereit; die WinForms-Ansicht ist die verbleibende Integration.
|
||||
— die Dienst-API steht bereit; die Freigabe-Ansicht in `src/ClawdDotNet.Desktop` ist
|
||||
die verbleibende Integration und der einzige offene Punkt von A2.
|
||||
- **Output-Scrubbing** greift auch hier auf den gespeicherten Argument-JSON, sobald es
|
||||
steht (eigener Roadmap-Punkt).
|
||||
- **Orders** (Handelsaufträge) reihen sich später als weitere `approve`-Aktionen ein.
|
||||
|
||||
@@ -116,7 +116,7 @@ tests/
|
||||
Die drei Projekte in `ClawdDotNet.slnx` unter einem Ordner `/tests/` eintragen.
|
||||
|
||||
> Nebenbefund: In `ClawdDotNet.slnx` fehlen vier Tool-Projekte (AgentComm, AgentSpawn,
|
||||
> AgentEditor, SocialMediaManager), obwohl sie in `ClawdDotNet.csproj` referenziert sind.
|
||||
> AgentEditor, SocialMediaManager), obwohl sie in `src/ClawdDotNet.App/ClawdDotNet.App.csproj` referenziert sind.
|
||||
> Sie sollten mit aufgenommen werden, sonst laufen sie in der IDE-Solution nicht mit.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,7 +21,8 @@ ClawdDotNet.sln
|
||||
│ ├── ClawdDotNet.Tools.MeinTool/ ← Dein Tool-Plugin
|
||||
│ │ ├── ClawdDotNet.Tools.MeinTool.csproj
|
||||
│ │ └── MeinToolTool.cs
|
||||
│ └── ClawdDotNet.Host/ ← WinForms-App (verweist auf Core + alle Tools)
|
||||
│ ├── ClawdDotNet.App/ ← Fachschicht ohne Oberflaeche (verweist auf Core + alle Tools)
|
||||
│ └── ClawdDotNet.Desktop/ ← Oberflaeche in Avalonia (verweist auf App)
|
||||
```
|
||||
|
||||
### Neues Tool-Projekt anlegen
|
||||
@@ -450,7 +451,7 @@ Das Logging-System trennt automatisch nach Modul. Wenn dein Tool den Logger aus
|
||||
Logs/
|
||||
├── 2026-05-12/
|
||||
│ ├── Core.log ← Engine, Scheduler, Config
|
||||
│ ├── Host.log ← WinForms UI
|
||||
│ ├── Host.log ← Oberflaeche
|
||||
│ ├── Tool_Database.log ← Database-Tool
|
||||
│ ├── Tool_FileRW.log ← FileRW-Tool
|
||||
│ ├── Tool_MeinTool.log ← Dein Tool!
|
||||
|
||||
@@ -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,27 @@
|
||||
# Archiv
|
||||
|
||||
Die vier Dokumente hier sind die **Entwicklungs-Prompts aus der Anfangszeit** des
|
||||
Projekts (Mai–Juli 2026). Sie haben ClawdDotNet aufgebaut und beschreiben deshalb
|
||||
den Stand von damals — unter anderem eine WinForms-Oberfläche mit WebView2, die es
|
||||
seit dem Frühjahrsputz vom 2026-08-23 nicht mehr gibt.
|
||||
|
||||
**Sie werden nicht mehr fortgeschrieben.** Wer den heutigen Stand sucht, findet ihn
|
||||
in [Roadmap](../Roadmap.md), [Bestandsaufnahme](../Bestandsaufnahme-2026-07.md) und
|
||||
den Konzept-Dokumenten daneben.
|
||||
|
||||
Aufgehoben werden sie aus zwei Gründen:
|
||||
|
||||
1. **Herkunft.** Sie erklären, warum das System so geschnitten ist, wie es
|
||||
geschnitten ist — der Schichtschnitt Core/Tools/App stammt von hier.
|
||||
2. **Eine Regel gilt weiter.** `ClawdDotNet_Prompt_InternetTools.md` enthält die
|
||||
Pflichtfelder `fetchedAt` / `dataAsOf` / `source`, an die sich alle Internet-Tools
|
||||
halten. Der [WebSearch-Umsetzungsplan](../umsetzungsplaene/UMSETZUNGSPLAN-WebSearch-Tool.md)
|
||||
verweist darauf. Zieht diese Regel eines Tages in ein eigenes Dokument um, kann
|
||||
die Datei ganz weg.
|
||||
|
||||
| Datei | Was darin steht | Was davon noch gilt |
|
||||
|---|---|---|
|
||||
| `ClawdDotNet_StartPrompt.md` | Gesamtentwurf, Kernklassen, Beispielkonfiguration | Schichtschnitt und Tool-Vertrag; Oberfläche und `configs/*.json` überholt |
|
||||
| `ClawdDotNet_Prompt_WebviewChatWinForms.md` | WinForms-Oberfläche mit WebView2-Chat | nichts — ersetzt durch `src/ClawdDotNet.Desktop` |
|
||||
| `ClawdDotNet_Prompt_TelegramClient.md` | Entwurf des TelegramClient-Tools | umgesetzt in `src/ClawdDotNet.Tools.TelegramClient` |
|
||||
| `ClawdDotNet_Prompt_InternetTools.md` | WebFetch, DirectAPI, WebMonitor | die Pflichtfelder-Regel (siehe oben) |
|
||||
@@ -20,7 +20,7 @@ Internetzugang ist bereits vorhanden, aber nur in eine Richtung:
|
||||
| `WebMonitor` | strukturierte Seiten überwachen | feste Zielseiten |
|
||||
|
||||
Alle drei liefern die Pflichtfelder `fetchedAt` / `dataAsOf` / `source` aus
|
||||
`ClawdDotNet_Prompt_InternetTools.md` und sind gegen SSRF abgesichert
|
||||
`docs/archiv/ClawdDotNet_Prompt_InternetTools.md` und sind gegen SSRF abgesichert
|
||||
(`UrlGuard` prüft Schema, private Netze und Whitelist, auch über Redirects
|
||||
hinweg — `UrlSanitizer` analog für DirectAPI).
|
||||
|
||||
@@ -153,7 +153,7 @@ Ergänzend im Tool:
|
||||
|
||||
### Slice 3 — Betrieb
|
||||
- [ ] Rechercheagent-Vorlage in `docs/InstanceSetupGuide.md` gemäß Abschnitt 4
|
||||
- [ ] `ClawdDotNet_Prompt_InternetTools.md` um das Tool ergänzen
|
||||
- [ ] `docs/archiv/ClawdDotNet_Prompt_InternetTools.md` um das Tool ergänzen
|
||||
- [ ] `docs/ToolDevelopmentGuide.md`: Steckbrief
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user