diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelAiTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelAiTests.cs new file mode 100644 index 0000000..5c49581 --- /dev/null +++ b/LehrerApp.Desktop.Tests/SettingsViewModelAiTests.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Text; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed partial class SettingsViewModelTests +{ + private const string UsageJson = """ + {"apiVersion":"1","schema":"lehrerbackend.usage.v1", + "period":{"days":28,"from":"2026-09-24T12:00:00","to":"2026-10-22T12:00:00"}, + "totals":{"calls":3,"chargedUsd":"0.300000","chargedEur":null}, + "byAction":[ + {"actionId":"plan","label":"Unterricht planen","operation":null,"calls":2,"chargedUsd":"0.200000","chargedEur":"0.180000"}, + {"actionId":"worksheet_rewrite_openai","label":"Text überarbeiten","operation":"rewrite","calls":1,"chargedUsd":"0.100000","chargedEur":null}], + "entries":[{"at":"2026-10-22T11:00:00","actionId":"plan","label":"Unterricht planen","operation":null,"status":"settled","chargedUsd":"0.100000","chargedEur":"0.090000"}], + "entriesTruncated":true,"features":{"spellcheck":true}} + """; + + private static AiPlanningService AiWithHandler(Func> handler) => + new(new HttpClient(new AccountHandler(handler)) { BaseAddress = new Uri("https://example.invalid/backend/") }, + new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new FakeCompetencyDomains(), + new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]), TestSupport.BuildAppLogger()); + + private static HttpResponseMessage JsonResponse(string json, HttpStatusCode status = HttpStatusCode.OK) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + private sealed class AccountHandler(Func> handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => handler(request); + } + + [Fact] + public async Task AiAccount_AuthUndVerbrauchWerdenGeladenUndWaehrungenBleibenGetrennt() + { + var requests = new List(); + var ai = AiWithHandler(request => + { + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal("test-token", request.Headers.Authorization?.Parameter); + requests.Add(request.RequestUri!.PathAndQuery); + return Task.FromResult(JsonResponse(request.RequestUri.AbsolutePath.EndsWith("status.php") + ? """{"balanceUsd":3.5,"balanceEur":3.1}""" : UsageJson)); + }); + var settings = TestSupport.BuildAiSettingsService(); + var vm = BuildViewModel(aiSettings: settings, aiPlanning: ai); + settings.SetCredentialsAndToken("test", "test-token"); + + await vm.RefreshAiAccountCommand.ExecuteAsync(null); + + Assert.Equal(["/backend/status.php", "/backend/api/v1/usage.php?days=28"], requests); + Assert.Contains("3,50 USD", vm.AiBalanceDisplay); + Assert.Contains("3,10 €", vm.AiBalanceDisplay); + Assert.True(vm.AiHasUsage); + Assert.Equal(2, vm.AiUsageGroups.Count); + Assert.Equal(3, vm.AiUsageGroups.Sum(g => g.Calls)); + Assert.Contains("0,30 USD", vm.AiUsageSummary); + Assert.DoesNotContain("€", vm.AiUsageSummary); // Euro-Gegenwert nicht für alle Aufrufe vorhanden + Assert.Equal("Unterricht planen", Assert.Single(vm.AiUsageEntries).Label); + Assert.Contains("neuesten 1", vm.AiUsageStatus); + Assert.Contains("2×", vm.AiFeatures.Single(f => f.ActionId == "plan").Usage); + Assert.Equal("Kostenlos", vm.AiFeatures.Single(f => f.ActionId == null).Usage); + Assert.False(vm.AiIsRefreshing); + } + + [Fact] + public async Task AiAccount_AltesBackend_BehaeltGuthabenUndErklaertFehlendenVerbrauch() + { + var ai = AiWithHandler(request => Task.FromResult(request.RequestUri!.AbsolutePath.EndsWith("status.php") + ? JsonResponse("""{"balanceUsd":2}""") : JsonResponse("{}", HttpStatusCode.NotFound))); + var settings = TestSupport.BuildAiSettingsService(); + var vm = BuildViewModel(aiSettings: settings, aiPlanning: ai); + settings.SetCredentialsAndToken("test", "token"); + + await vm.RefreshAiAccountCommand.ExecuteAsync(null); + + Assert.Equal("Guthaben: 2,00 USD", vm.AiBalanceDisplay); + Assert.Contains("noch keine Verbrauchsübersicht", vm.AiUsageStatus); + Assert.Empty(vm.AiUsageGroups); + Assert.False(vm.AiIsRefreshing); + } + + [Fact] + public async Task AiAccount_AbmeldenWaerendLaden_VerhindertRueckkehrDerKontodaten() + { + var response = new TaskCompletionSource(); + var settings = TestSupport.BuildAiSettingsService(); + var vm = BuildViewModel(aiSettings: settings, aiPlanning: AiWithHandler(_ => response.Task)); + settings.SetCredentialsAndToken("test", "token"); + var refresh = vm.RefreshAiAccountCommand.ExecuteAsync(null); + Assert.True(vm.AiIsRefreshing); + + vm.AiLogoutCommand.Execute(null); + response.SetResult(JsonResponse("""{"balanceUsd":99}""")); + await refresh; + + Assert.False(settings.IsLoggedIn); + Assert.False(vm.AiIsLoggedIn); + Assert.False(vm.AiIsRefreshing); + Assert.Empty(vm.AiBalanceDisplay); + Assert.Empty(vm.AiUsageEntries); + Assert.Empty(vm.AiUsageGroups); + Assert.Contains("Nach der Anmeldung", vm.AiUsageStatus); + } + + [Theory] + [InlineData("{}")] + [InlineData("Fehler")] + [InlineData("null")] + public async Task AiAccount_UngueltigesGuthaben_LiefertVerstaendlichenFehler(string json) + { + var ai = AiWithHandler(_ => Task.FromResult(JsonResponse(json))); + var error = await Assert.ThrowsAsync(() => ai.GetAccountBalanceAsync("token")); + Assert.Contains("Guthaben", error.Message); + } + + [Theory] + [InlineData("0.300000", "null")] + [InlineData("lehrerbackend.usage.v1", "unbekannt")] + [InlineData("2026-10-22T11:00:00", "ungueltig")] + public void AiUsage_UngueltigeAntwortWirdAbgelehnt(string oldValue, string newValue) + { + Assert.Throws(() => AiUsageReport.Parse(UsageJson.Replace(oldValue, newValue))); + } + + [Fact] + public async Task AiAccount_NetzwerkfehlerUndAbgelaufeneAnmeldungWerdenErklaert() + { + var unavailable = AiWithHandler(_ => throw new HttpRequestException()); + Assert.Contains("nicht erreichbar", (await Assert.ThrowsAsync(() => unavailable.GetUsageAsync("token"))).Message); + var expired = AiWithHandler(_ => Task.FromResult(JsonResponse("{}", HttpStatusCode.Unauthorized))); + Assert.Contains("Anmeldung abgelaufen", (await Assert.ThrowsAsync(() => expired.GetUsageAsync("token"))).Message); + } +} diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index f97691c..e09b49f 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -11,7 +11,7 @@ using LehrerApp.Templating; namespace LehrerApp.Desktop.Tests; -public sealed class SettingsViewModelTests +public sealed partial class SettingsViewModelTests { private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, FakeSupervisionDuties? supervisionDuties = null, @@ -19,7 +19,7 @@ public sealed class SettingsViewModelTests FakeCompetencyCatalogVersions? competencyVersions = null, EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null, SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null, - TrashViewModel? trashTab = null) + TrashViewModel? trashTab = null, AiSettingsService? aiSettings = null, AiPlanningService? aiPlanning = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState, // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. @@ -37,7 +37,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), - new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), aiSettings ?? TestSupport.BuildAiSettingsService(), aiPlanning ?? TestSupport.BuildAiPlanningService(), TestSupport.BuildMcpSettingsService(), TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildWebUntisSettingsService(), diff --git a/LehrerApp.Desktop/Services/AiAccountBalance.cs b/LehrerApp.Desktop/Services/AiAccountBalance.cs new file mode 100644 index 0000000..ccf737f --- /dev/null +++ b/LehrerApp.Desktop/Services/AiAccountBalance.cs @@ -0,0 +1,5 @@ +using System.Text.Json.Serialization; + +namespace LehrerApp.Desktop.Services; + +public sealed record AiAccountBalance([property: JsonRequired] decimal BalanceUsd, decimal? BalanceEur); diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 3619955..d8802a8 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -146,25 +146,49 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, return result?.Token ?? throw new AiBackendException("Unerwartete Antwort des KI-Dienstes."); } - public async Task GetBalanceAsync(string token) - { - using var req = new HttpRequestMessage(HttpMethod.Get, "status.php"); - req.Headers.Authorization = new("Bearer", token); + public async Task GetBalanceAsync(string token) => + (await GetAccountBalanceAsync(token)).BalanceUsd; + public async Task GetAccountBalanceAsync(string token) + { + using var resp = await GetAccountResponseAsync("status.php", token); + try + { + return await resp.Content.ReadFromJsonAsync(JsonOptions) + ?? throw new AiBackendException("Das Guthaben des KI-Dienstes konnte nicht verarbeitet werden."); + } + catch (JsonException) + { + throw new AiBackendException("Das Guthaben des KI-Dienstes konnte nicht verarbeitet werden."); + } + } + + public async Task GetUsageAsync(string token) + { + using var resp = await GetAccountResponseAsync("api/v1/usage.php?days=28", token); + return AiUsageReport.Parse(await resp.Content.ReadAsStringAsync()); + } + + private async Task GetAccountResponseAsync(string path, string token) + { + using var req = new HttpRequestMessage(HttpMethod.Get, path); + req.Headers.Authorization = new("Bearer", token); HttpResponseMessage resp; try { resp = await http.SendAsync(req); } - catch (HttpRequestException) + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) { - throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen."); + throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte später erneut versuchen."); } - - if (resp.StatusCode == HttpStatusCode.Unauthorized) - throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden."); - if (!resp.IsSuccessStatusCode) - throw new AiBackendException("Guthaben konnte nicht abgerufen werden."); - - var result = await resp.Content.ReadFromJsonAsync(JsonOptions); - return result?.BalanceUsd ?? 0m; + if (resp.IsSuccessStatusCode) return resp; + var status = resp.StatusCode; + resp.Dispose(); + throw new AiBackendException(status switch + { + HttpStatusCode.Unauthorized => "Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.", + HttpStatusCode.NotFound when path.StartsWith("api/v1/usage") => + "Dieses Backend stellt noch keine Verbrauchsübersicht bereit.", + _ => "Die Kontodaten des KI-Dienstes konnten nicht abgerufen werden." + }); } /// Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes. @@ -695,7 +719,6 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, } private class LoginResult { public string Token { get; set; } = ""; } - private class BalanceResult { public decimal BalanceUsd { get; set; } } private class BackendErrorResult { public string? Error { get; set; } diff --git a/LehrerApp.Desktop/Services/AiUsageReport.cs b/LehrerApp.Desktop/Services/AiUsageReport.cs new file mode 100644 index 0000000..a673eb0 --- /dev/null +++ b/LehrerApp.Desktop/Services/AiUsageReport.cs @@ -0,0 +1,86 @@ +using System.Globalization; +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +/// Verbrauch einer Aktion im Zeitraum, z. B. „Text korrigieren“ oder eine Planung der LehrerApp (alle Apps am selben Backend). +/// Funktion der Arbeitsblatt-API; null bei Aktionen anderer Apps. +/// Nur, wenn für jeden Aufruf der Euro-Gegenwert dokumentiert ist; sonst null (der Dienst schätzt nichts). +public sealed record AiUsageGroup(string ActionId, string Label, string? Operation, int Calls, decimal ChargedUsd, decimal? ChargedEur); + +/// Ein berechneter Aufruf. ist die Uhrzeit der Datenbank des Dienstes, ohne Zeitzone. +public sealed record AiUsageEntry(DateTime At, string ActionId, string Label, string? Operation, string Status, decimal ChargedUsd, decimal? ChargedEur); + +/// Ob die Rechtschreibprüfung über das Backend freigegeben ist; null, wenn der Dienst es nicht meldet. +public sealed record AiUsageReport(int Days, DateTime From, DateTime To, int Calls, decimal ChargedUsd, decimal? ChargedEur, + IReadOnlyList ByAction, IReadOnlyList Entries, bool EntriesTruncated, bool? SpellcheckGranted) +{ + /// + /// Liest die Antwort von api/v1/usage.php. Unbekannte zusätzliche Felder werden übergangen (der Dienst darf wachsen), fehlende oder + /// unplausible Pflichtfelder sind ein Fehler. + /// + public static AiUsageReport Parse(string json) + { + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 }); + var root = document.RootElement; + Need(root.GetProperty("apiVersion").GetString() == "1" && root.GetProperty("schema").GetString() == "lehrerbackend.usage.v1"); + var period = root.GetProperty("period"); + var days = period.GetProperty("days").GetInt32(); + var totals = root.GetProperty("totals"); + var groups = new List(); + foreach (var item in root.GetProperty("byAction").EnumerateArray()) + { + Need(groups.Count < 256); + groups.Add(new(Text(item, "actionId", 64), Text(item, "label", 200), Operation(item), item.GetProperty("calls").GetInt32(), + RequiredMoney(item, "chargedUsd"), Money(item, "chargedEur"))); + } + var entries = new List(); + foreach (var item in root.GetProperty("entries").EnumerateArray()) + { + Need(entries.Count < 1000); + entries.Add(new(Date(item.GetProperty("at").GetString()), Text(item, "actionId", 64), Text(item, "label", 200), Operation(item), Text(item, "status", 32), + RequiredMoney(item, "chargedUsd"), Money(item, "chargedEur"))); + } + bool? spellcheck = root.TryGetProperty("features", out var features) && features.TryGetProperty("spellcheck", out var flag) && flag.ValueKind is JsonValueKind.True or JsonValueKind.False + ? flag.GetBoolean() : null; + var calls = totals.GetProperty("calls").GetInt32(); + Need(days is >= 1 and <= 366 && calls >= 0 && groups.All(g => g.Calls >= 0 && g.ChargedUsd >= 0 && g.ChargedEur is null or >= 0) && entries.All(e => e.ChargedUsd >= 0)); + return new(days, Date(period.GetProperty("from").GetString()), Date(period.GetProperty("to").GetString()), calls, RequiredMoney(totals, "chargedUsd"), Money(totals, "chargedEur"), + groups, entries, root.GetProperty("entriesTruncated").GetBoolean(), spellcheck); + } + catch (Exception e) when (e is JsonException or InvalidOperationException or KeyNotFoundException or FormatException or ArgumentException or OverflowException) + { throw new AiBackendException("Die Verbrauchsdaten des KI-Dienstes konnten nicht verarbeitet werden."); } + } + + private static void Need(bool condition) { if (!condition) throw new FormatException(); } + + private static string Text(JsonElement item, string name, int max) + { + var value = item.GetProperty(name).GetString(); + Need(!string.IsNullOrWhiteSpace(value) && value.Length <= max); + return value!; + } + + private static string? Operation(JsonElement item) => + item.TryGetProperty("operation", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static decimal RequiredMoney(JsonElement item, string name) => + Money(item, name) ?? throw new FormatException(); + + private static decimal? Money(JsonElement item, string name) + { + if (!item.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null; + // Dezimaltext mit höchstens sechs Nachkommastellen wie im ganzen Vertrag, nie eine Fließkommazahl + var text = value.GetString(); + Need(text is not null && System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d{1,9}(\.\d{1,6})?$")); + return decimal.Parse(text!, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + } + + private static DateTime Date(string? text) + { + Need(DateTime.TryParseExact(text, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)); + return DateTime.ParseExact(text!, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AiSettings.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AiSettings.cs index 8a6850f..5b0480f 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AiSettings.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.AiSettings.cs @@ -1,54 +1,115 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using LehrerApp.Core.Interfaces; -using LehrerApp.Core.Models; -using LehrerApp.Core.Services; -using LehrerApp.Data; using LehrerApp.Desktop.Services; -using LehrerApp.Desktop.ViewModels.Planning; -using LehrerApp.Sync; -using LehrerApp.Sync.Crypto; using System.Collections.ObjectModel; using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.Json.Serialization; namespace LehrerApp.Desktop.ViewModels.Settings; public partial class SettingsViewModel { - // ── KI-Unterstützung (4.5.9) ────────────────────────────────────────────── - - [ObservableProperty] private bool _aiEnabled; + [ObservableProperty] private bool _aiEnabled; [ObservableProperty] private string _aiUsername = ""; [ObservableProperty] private string _aiPassword = ""; [ObservableProperty] private string _aiLoginError = ""; - [ObservableProperty] private bool _aiIsLoggedIn; + [ObservableProperty] private bool _aiIsLoggedIn; [ObservableProperty] private string _aiBalanceDisplay = ""; + [ObservableProperty] private bool _aiIsRefreshing; + [ObservableProperty] private string _aiAccountStatus = ""; + [ObservableProperty] private string _aiUsageSummary = ""; + [ObservableProperty] private string _aiUsageStatus = "Nach der Anmeldung erscheint hier der Verbrauch der letzten vier Wochen."; + [ObservableProperty] private string _aiUsagePeriod = ""; + [ObservableProperty] private bool _aiHasUsage; + private int _aiRefreshVersion; - // ── KI-Unterstützung: Laden / Anmelden / Abmelden ──────────────────────── + public ObservableCollection AiFeatures { get; } = + [ + new("plan", "Unterricht planen", "Einheiten und einzelne Stunden entwerfen oder überarbeiten; Vorschläge vor der Übernahme prüfen."), + new("explain", "Didaktik erklären", "Didaktische Erläuterungen zu einer vorgeschlagenen Unterrichtsstunde abrufen."), + new("gbu", "Gefährdungsbeurteilung", "Einen GBU-Entwurf zum geplanten Experiment erstellen und im Assistenten weiterbearbeiten."), + new("untis-status", "Fehlzeiten prüfen", "Vorschläge für den Entschuldigungsstatus beim WebUntis-Abgleich erhalten."), + new(null, "Stoffdaten nachschlagen", "Stoffe im GBU-Assistenten in der RiSU-Stoffliste suchen. Keine KI, kein Guthabenverbrauch.", "Kostenlos") + ]; + public ObservableCollection AiUsageGroups { get; } = []; + public ObservableCollection AiUsageEntries { get; } = []; private void LoadAiSettings() { AiEnabled = _aiSettings.Enabled; AiUsername = _aiSettings.Username; AiIsLoggedIn = _aiSettings.IsLoggedIn; - if (AiIsLoggedIn) _ = RefreshAiBalance(); + if (AiIsLoggedIn) _ = RefreshAiAccount(); } partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value); - private async Task RefreshAiBalance() + [RelayCommand] + private async Task RefreshAiAccount() { var token = _aiSettings.GetToken(); if (token is null) return; + var version = ++_aiRefreshVersion; + bool Current() => version == _aiRefreshVersion && token == _aiSettings.GetToken(); + AiIsRefreshing = true; + AiAccountStatus = ""; + AiBalanceDisplay = "Guthaben wird geladen …"; + ClearAiUsage("Verbrauch wird geladen …"); try { - var balance = await _aiPlanning.GetBalanceAsync(token); - AiBalanceDisplay = $"Guthaben: {balance:0.00} €"; + // Die Übersicht bleibt nutzbar, wenn ein älteres Backend nur das Guthaben liefert. + try + { + var balance = await _aiPlanning.GetAccountBalanceAsync(token); + if (!Current()) return; + AiBalanceDisplay = "Guthaben: " + AiMoney.Format(balance.BalanceUsd, balance.BalanceEur); + } + catch (AiBackendException ex) + { + if (!Current()) return; + AiBalanceDisplay = "Guthaben nicht verfügbar"; + AiAccountStatus = ex.Message; + } + + try + { + var usage = await _aiPlanning.GetUsageAsync(token); + if (!Current()) return; + AiUsageStatus = usage.Calls == 0 ? $"In den letzten {usage.Days} Tagen wurde nichts berechnet." : ""; + AiUsagePeriod = $"{usage.From:dd.MM.yyyy HH:mm} bis {usage.To:dd.MM.yyyy HH:mm} (Backend-Zeit)"; + AiUsageSummary = $"Gesamt: {AiMoney.Format(usage.ChargedUsd, usage.ChargedEur)} · {usage.Calls} Aufrufe"; + foreach (var group in usage.ByAction.OrderByDescending(g => g.ChargedUsd)) + AiUsageGroups.Add(new(group.Label, group.Calls, AiMoney.Format(group.ChargedUsd, group.ChargedEur), + usage.ChargedUsd > 0 ? (double)(100 * group.ChargedUsd / usage.ChargedUsd) : 0)); + foreach (var entry in usage.Entries.OrderByDescending(e => e.At)) + AiUsageEntries.Add(new(entry.At.ToString("dd.MM.yyyy HH:mm"), entry.Label, + AiMoney.Format(entry.ChargedUsd, entry.ChargedEur))); + foreach (var feature in AiFeatures.Where(f => f.ActionId is not null)) + { + var groups = usage.ByAction.Where(g => g.ActionId == feature.ActionId).ToArray(); + feature.Usage = groups.Length == 0 ? "Keine berechneten Aufrufe" : + $"{groups.Sum(g => g.Calls)}× · {AiMoney.Format(groups.Sum(g => g.ChargedUsd), groups.All(g => g.ChargedEur.HasValue) ? groups.Sum(g => g.ChargedEur!.Value) : null)}"; + } + AiHasUsage = usage.Calls > 0; + if (usage.EntriesTruncated) + AiUsageStatus = $"Die Liste zeigt die neuesten {usage.Entries.Count} Aufrufe. Die Summen enthalten alle Aufrufe im Zeitraum."; + } + catch (AiBackendException ex) + { + if (Current()) AiUsageStatus = ex.Message; + } } - catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; } + finally { if (Current()) AiIsRefreshing = false; } + } + + private void ClearAiUsage(string status) + { + AiUsageGroups.Clear(); + AiUsageEntries.Clear(); + AiHasUsage = false; + AiUsageSummary = ""; + AiUsagePeriod = ""; + AiUsageStatus = status; + foreach (var feature in AiFeatures.Where(f => f.ActionId is not null)) feature.Usage = "Verbrauch nicht verfügbar"; } [RelayCommand] @@ -64,18 +125,45 @@ public partial class SettingsViewModel { var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword); _aiSettings.SetCredentialsAndToken(AiUsername, token); - AiPassword = ""; AiIsLoggedIn = true; - await RefreshAiBalance(); + await RefreshAiAccount(); } catch (AiBackendException ex) { AiLoginError = ex.Message; } + finally { AiPassword = ""; } } [RelayCommand] private void AiLogout() { + ++_aiRefreshVersion; _aiSettings.Logout(); AiIsLoggedIn = false; + AiIsRefreshing = false; AiBalanceDisplay = ""; + AiAccountStatus = ""; + AiLoginError = ""; + AiPassword = ""; + ClearAiUsage("Nach der Anmeldung erscheint hier der Verbrauch der letzten vier Wochen."); } } + +public partial class AiFeatureItem(string? actionId, string name, string description, string usage = "Verbrauch nach Anmeldung") : ObservableObject +{ + public string? ActionId { get; } = actionId; + public string Name { get; } = name; + public string Description { get; } = description; + [ObservableProperty] private string _usage = usage; +} + +public sealed record AiUsageGroupItem(string Label, int Calls, string Amount, double Percent) +{ + public string CallsDisplay => $"{Calls}×"; +} +public sealed record AiUsageEntryItem(string When, string Label, string Amount); + +internal static class AiMoney +{ + public static string Format(decimal usd, decimal? eur) => Number(usd) + " USD" + + (eur is { } value ? $" (≈ {Number(value)} €)" : ""); + private static string Number(decimal value) => value.ToString(value != 0 && Math.Abs(value) < 0.1m ? "0.000000" : "0.00", CultureInfo.GetCultureInfo("de-DE")); +} diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index 2b1d19f..33cebd7 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -1063,15 +1063,15 @@ - + - + + Text="KI-Anfragen werden über deinen Zugang am gemeinsamen Backend abgerechnet. Ein Klick auf die jeweilige KI-Aktion startet die Anfrage und kann Guthaben verbrauchen; es gibt keine zusätzliche Kostenbestätigung. Vorschläge prüfst du vor der Übernahme."/> - + @@ -1090,9 +1090,71 @@ -