Initial commit: ClawdDotNet
Import des bestehenden Projektstands in Git. - .NET 10 WinForms Anwendung (Multi-Agent / Tool-System) - .gitignore fuer Build-Artefakte, Secrets und Runtime-Daten ergaenzt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.**
|
||||
Reference in New Issue
Block a user