# 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 ``` --- ## 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 _logger; // Eingehende Nachrichten vom Browser → C# public event Action? MessageReceived; public WebViewBridge( Microsoft.Web.WebView2.WinForms.WebView2 webView, ILogger 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( 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 _logger; private WebViewBridge? _bridge; private string? _selectedAgentId; // Offene Einzelchat-Fenster: AgentId → frm_chat private readonly Dictionary _chatWindows = new(); public frm_main(InstanceConfig instance, AgentEngine engine, AgentScheduler scheduler, ILogger 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 _logger; private WebViewBridge? _bridge; public frm_chat(AgentConfig agentConfig, AgentEngine engine, ILogger 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().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(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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(); registry.Register(new DatabaseTool()); registry.Register(new FileRwTool()); registry.Register(new MailTool()); // 5. Scheduler starten var scheduler = provider.GetRequiredService(); await scheduler.StartAsync(CancellationToken.None); // 6. WinForms starten var mainForm = provider.GetRequiredService(); 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 ChatAsync(string agentId, string userMessage, CancellationToken ct); // Chat-Verlauf aus dem StateManager laden // Rückgabe: Liste von { role, content, timestamp } Task> 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 ``` --- ## 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.**