Das Suffix ".Avalonia" gab es nur, weil daneben ein WinForms-IBKRTrader.App
stand. Das ist seit L5 weg, also faellt auch das Suffix. Git erkennt alle
Dateien als Umbenennung; Assembly, Wurzel-Namensraum und die
avares://-Ressourcen-URI sind mitgezogen.
Nebeneffekt der Umbenennung: die global::Avalonia-Qualifizierungen entfallen.
Sie waren noetig, weil der Namensraum IBKRTrader.App.Avalonia das
Avalonia-Paket verdeckt hat - ein Ueberbleibsel genau der Namensgebung, die
jetzt weg ist.
Inhaltlich falsch gewordene Aussagen berichtigt - das waren die eigentlichen
Ueberbleibsel, nicht die Kommentare:
- .agents/rules/grundregeln.md schrieb weiterhin "C# .NET 10 WinForms",
RichTextBox-Logging, LauncherForm und PropertyGrid vor. Das ist die Regel,
nach der kuenftig gearbeitet wird - sie haette die Portierung Stueck fuer
Stueck rueckgaengig gemacht. Jetzt: Avalonia, keine Plattform-Suffixe, die
11er-Pinnung mit Begruendung, dazu die beiden Regeln, die uns in L1b am
meisten gekostet haben (UTC persistieren + AppTimeZone statt DateTime.Now;
jede Formatierung mit ausdruecklichem IFormatProvider).
- Core: LogEntry ("wird in RichTextBox geschrieben"), IWorker/WorkerEngine/
WorkerInfo ("DataGridView-Zeile"/"-Binding"), ModuleView ("die
WinForms-Shell castet auf Form").
- Doku: ARCHITECTURE (Modul-Ui-Ordner, "designbare Forms mit Initialize"),
KONZEPT-Modul-Accounting ("UI (WinForms, ein Fenster mit Tabs)").
BEWUSST STEHEN GEBLIEBEN sind die Kommentare, die WinForms nur als
Begruendung nennen - warum LoggingService ein Ereignis hat statt einer
RichTextBox, warum ModuleView Func<object> liefert, warum es benannte
Record-Zeilentypen gibt, warum die Einstellungsmaske aus Attributen entsteht.
Das ist die Herleitung des heutigen Entwurfs; ohne sie sieht spaeter jede
dieser Stellen nach Umstaendlichkeit ohne Grund aus.
KONZEPT-Linux-Portierung.md bekommt einen Statusvermerk: umgesetzt, die
Pfadangaben im Fundstellenverzeichnis beziehen sich auf den alten Aufbau.
Zwei Abweichungen von der Schaetzung sind dort festgehalten - der geringere
Aufwand dank der PolytraderSharp-Vorlage, und dass die dort empfohlene
InvariantGlobalization ein Fehler gewesen waere.
Verifiziert: Build 0 Fehler/0 Warnungen, 193 Tests gruen, Smoke-UI
konstruiert alle 7 Ansichten + Launcher + Dialog, Daemon-Prueflauf OK,
publish -r linux-x64 fuer beide Einstiegspunkte fehlerfrei.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
8.5 KiB
C#
198 lines
8.5 KiB
C#
using System.Text.Json;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Markup.Xaml;
|
||
using Avalonia.Threading;
|
||
using IBKRTrader.App.Shell;
|
||
using IBKRTrader.App.ViewModels;
|
||
using IBKRTrader.Core.Analytics;
|
||
using IBKRTrader.Core.Logging;
|
||
using IBKRTrader.Core.Modularity;
|
||
using IBKRTrader.Core.Time;
|
||
using IBKRTrader.Modules.Supervisor.Agent;
|
||
using IBKRTrader.Modules.Supervisor.Persistence;
|
||
using IBKRTrader.Modules.Supervisor.Services;
|
||
|
||
namespace IBKRTrader.App.Views.Modules;
|
||
|
||
/// <summary>
|
||
/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar),
|
||
/// Dossier-Browser, Berichte und Hinweise. Strikt read-only – kein Tool kann handeln oder schreiben.
|
||
///
|
||
/// <para>DB- und Agent-Zugriffe laufen NUR auf Nutzerinteraktion, nie im Konstruktor.</para>
|
||
/// </summary>
|
||
public partial class SupervisorWindow : Window
|
||
{
|
||
private readonly SupervisorAgent _agent;
|
||
private readonly DossierService _dossiers;
|
||
private readonly ISupervisorReportRepository _reports;
|
||
private readonly LoggingService _logger;
|
||
|
||
public SupervisorWindow(IModuleUiHost uiHost,
|
||
SupervisorAgent agent,
|
||
DossierService dossiers,
|
||
ISupervisorReportRepository reports,
|
||
LoggingService logger)
|
||
{
|
||
_agent = agent;
|
||
_dossiers = dossiers;
|
||
_reports = reports;
|
||
_logger = logger;
|
||
|
||
InitializeComponent();
|
||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "supervisor.main");
|
||
|
||
var profiles = this.FindControl<ComboBox>("ProfileBox")!;
|
||
profiles.ItemsSource = SupervisorProfiles.All.Select(p => p.Name).ToList();
|
||
profiles.SelectedIndex = 0;
|
||
|
||
this.FindControl<Button>("AskButton")!.Click += async (_, _) => await AskAsync();
|
||
this.FindControl<Button>("LoadSignalsButton")!.Click += (_, _) => LoadSignals();
|
||
this.FindControl<Button>("LoadReportsButton")!.Click += (_, _) => LoadReports();
|
||
this.FindControl<DataGrid>("SignalsGrid")!.SelectionChanged += (_, _) => ShowSelectedDossier();
|
||
|
||
this.FindControl<SelectableTextBlock>("InfoText")!.Text = BuildInfoText();
|
||
|
||
SetStatus("Bereit.");
|
||
}
|
||
|
||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||
|
||
// ── Analyse ──────────────────────────────────────────────────────────────
|
||
|
||
private async Task AskAsync()
|
||
{
|
||
var questionBox = this.FindControl<TextBox>("QuestionBox")!;
|
||
var question = (questionBox.Text ?? "").Trim();
|
||
if (question.Length == 0) return;
|
||
|
||
var askButton = this.FindControl<Button>("AskButton")!;
|
||
var answer = this.FindControl<SelectableTextBlock>("AnswerText")!;
|
||
|
||
askButton.IsEnabled = false;
|
||
answer.Text = "";
|
||
|
||
var profile = SupervisorProfiles.ByName((string?)this.FindControl<ComboBox>("ProfileBox")!.SelectedItem ?? "");
|
||
|
||
// Der Agent meldet Tool-Aufrufe im Verlauf – die sollen live sichtbar sein, nicht erst
|
||
// am Ende. Progress<T> meldet auf dem erfassten Kontext; der Dispatcher-Wechsel bleibt
|
||
// trotzdem stehen, weil der Agent aus einem Worker-Thread berichten kann.
|
||
var progress = new Progress<string>(AppendLine);
|
||
|
||
try
|
||
{
|
||
SetStatus("Analyse läuft …");
|
||
var result = await _agent.AskAsync(question, profile: profile, progress: progress);
|
||
|
||
AppendLine("");
|
||
AppendLine("─── Antwort ───");
|
||
AppendLine(result.Answer);
|
||
|
||
_reports.Insert(new SupervisorReport
|
||
{
|
||
Profile = profile.Name,
|
||
Model = SupervisorAgent.DefaultModel,
|
||
Question = question,
|
||
Answer = result.Answer,
|
||
ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })),
|
||
ToolCallCount = result.ToolInvocations.Count,
|
||
PromptTokens = result.PromptTokens,
|
||
CompletionTokens = result.CompletionTokens
|
||
});
|
||
|
||
SetStatus($"Analyse abgeschlossen ({result.ToolInvocations.Count} Tool-Aufrufe).");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
AppendLine("");
|
||
AppendLine($"FEHLER: {ex.Message}");
|
||
_logger.Warn("Supervisor", $"Analyse fehlgeschlagen: {ex.Message}");
|
||
SetStatus($"Analyse fehlgeschlagen: {ex.Message}");
|
||
}
|
||
finally
|
||
{
|
||
askButton.IsEnabled = true;
|
||
}
|
||
}
|
||
|
||
private void AppendLine(string text)
|
||
{
|
||
if (!Dispatcher.UIThread.CheckAccess())
|
||
{
|
||
Dispatcher.UIThread.Post(() => AppendLine(text));
|
||
return;
|
||
}
|
||
|
||
var block = this.FindControl<SelectableTextBlock>("AnswerText")!;
|
||
block.Text += text + Environment.NewLine;
|
||
this.FindControl<ScrollViewer>("AnswerScroller")!.ScrollToEnd();
|
||
}
|
||
|
||
// ── Dossier ──────────────────────────────────────────────────────────────
|
||
|
||
private void LoadSignals()
|
||
{
|
||
try
|
||
{
|
||
var signals = _dossiers.RecentSignals(200);
|
||
this.FindControl<DataGrid>("SignalsGrid")!.ItemsSource = signals;
|
||
SetStatus($"{signals.Count} Signale geladen.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Warn("Supervisor", $"Signale laden fehlgeschlagen: {ex.Message}");
|
||
SetStatus($"Signale laden fehlgeschlagen: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private void ShowSelectedDossier()
|
||
{
|
||
if (this.FindControl<DataGrid>("SignalsGrid")!.SelectedItem is not SignalSummary s) return;
|
||
|
||
var target = this.FindControl<SelectableTextBlock>("DossierText")!;
|
||
try { target.Text = DossierBuilder.ToMarkdown(_dossiers.BuildForSignal(s.SignalId)); }
|
||
catch (Exception ex) { target.Text = $"FEHLER: {ex.Message}"; }
|
||
}
|
||
|
||
// ── Berichte ─────────────────────────────────────────────────────────────
|
||
|
||
private void LoadReports()
|
||
{
|
||
try
|
||
{
|
||
var rows = _reports.GetRecent(100)
|
||
.Select(r => new SupervisorReportRow(
|
||
r.CreatedAt, r.Profile, r.Model, r.Question, r.ToolCallCount,
|
||
$"{r.PromptTokens} / {r.CompletionTokens}"))
|
||
.ToList();
|
||
|
||
this.FindControl<DataGrid>("ReportsGrid")!.ItemsSource = rows;
|
||
SetStatus($"{rows.Count} Berichte geladen.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Warn("Supervisor", $"Berichte laden fehlgeschlagen: {ex.Message}");
|
||
SetStatus($"Berichte laden fehlgeschlagen: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ── Hinweise ─────────────────────────────────────────────────────────────
|
||
|
||
private static string BuildInfoText()
|
||
{
|
||
var keySet = !string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider());
|
||
|
||
return
|
||
"Supervisor – read-only Analyse und Forensik über alle Module." + Environment.NewLine + Environment.NewLine +
|
||
"OpenRouter-Key: env IBKRTRADER_OPENROUTER_KEY oder Datei 'openrouter.key' (gitignored)." + Environment.NewLine +
|
||
$" Status: {(keySet ? "gesetzt" : "NICHT gesetzt – Analyse nicht verfügbar")}" + Environment.NewLine + Environment.NewLine +
|
||
"Tagesbericht (opt-in): env IBKRTRADER_SUPERVISOR_DAILY = Stunde 0–23." + Environment.NewLine +
|
||
$" Die Stunde gilt in der Betriebszeitzone dieser Instanz ({AppTimeZone.CurrentId})." + Environment.NewLine +
|
||
"MCP-Light (opt-in): env IBKRTRADER_MCP_PORT = Port (bindet nur 127.0.0.1)." + Environment.NewLine + Environment.NewLine +
|
||
"Sicherheit: OpenRouter ist ein bewusst freigegebener externer Datenempfänger. Gesendet werden " +
|
||
"nur Analyse-Daten der Tools, niemals Secrets. Kein Tool kann handeln oder schreiben.";
|
||
}
|
||
|
||
private void SetStatus(string text) =>
|
||
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
|
||
}
|