feat: AI log admin view, retention and web search settings in the UI

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 21:57:46 +02:00
1 parent e0fdfbbdda
commit bf5a1b9a89
9 files changed
+336 -44

No files matched your search

@@ -23,7 +23,7 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
table: "FamilySettings",
type: "int",
nullable: false,
defaultValue: 0);
defaultValue: 90);
migrationBuilder.AddColumn<bool>(
name: "AllowWebSearch",
@@ -0,0 +1,67 @@
@using FamilyPlanner.Domain.Ai
@using FamilyPlanner.Shared.Ai
@inject IAiApi Api
@* Detailansicht eines KI-Aufrufs: oben der gesendete Prompt, darunter die Antwort. Nur Text, nie als HTML. *@
<MudDialog>
<DialogContent>
@if (_loading)
{
<LoadingIndicator />
}
else if (_detail is null)
{
<MudAlert Severity="Severity.Error" role="alert">Der Eintrag konnte nicht geladen werden.</MudAlert>
}
else
{
<MudText Typo="Typo.body2" Class="mb-3 fp-ai-exchange-meta">
@UserManagement.FormatDate(_detail.At) · @_detail.UserName · @AiRules.PurposeName(_detail.Purpose) · @_detail.ProviderName (@_detail.Model) ·
@(_detail.Success ? "OK" : "Fehler") · @_detail.DurationMs ms
@(_detail.InputTokens is { } i ? $" · {i}→{_detail.OutputTokens} Tokens" : string.Empty)
@(_detail.WebSearch ? " · Online-Suche erlaubt" : string.Empty)
@(_detail.ImageCount > 0 ? $" · {_detail.ImageCount} Bild(er) (nicht gespeichert)" : string.Empty)
</MudText>
@if (!_detail.ContentStored)
{
<MudAlert Severity="Severity.Info" Class="fp-ai-exchange-nocontent">
Für diesen Aufruf wurde kein Inhalt gespeichert (privater KI-Chat). Das lässt sich unter „Einstellungen → KI-Protokoll“ ändern; es gilt nur für künftige Aufrufe.
</MudAlert>
}
else
{
<MudText Typo="Typo.subtitle2">Gesendeter Prompt</MudText>
<pre class="fp-ai-prompt" style="white-space:pre-wrap;word-break:break-word;margin:0 0 16px 0;">@(string.IsNullOrEmpty(_detail.Prompt) ? "(leer)" : _detail.Prompt)</pre>
<MudText Typo="Typo.subtitle2">Antwort der KI@(_detail.Error is null ? string.Empty : $" – Fehler: {_detail.Error}")</MudText>
<pre class="fp-ai-response" style="white-space:pre-wrap;word-break:break-word;margin:0;">@(string.IsNullOrEmpty(_detail.Response) ? "(leer)" : _detail.Response)</pre>
}
@if (!_detail.ContentStored && _detail.Error is not null)
{
<MudText Typo="Typo.body2" Class="mt-2">Fehler: @_detail.Error</MudText>
}
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Schließen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public Guid Id { get; set; }
private AiExchangeDetailDto? _detail;
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
_detail = (await Api.GetExchangeAsync(Id)).Value;
_loading = false;
}
private void Close() => Dialog?.Close();
}
@@ -0,0 +1,148 @@
@using FamilyPlanner.Domain.Ai
@using FamilyPlanner.Shared.Ai
@inject IAiApi Api
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@* KI-Protokoll (Phase 12b): alle KI-Aufrufe mit Prompt und Antwort, nur für Administratoren. Alle Texte werden nur als Text angezeigt. *@
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mt-6 mb-1">KI-Protokoll</MudText>
<MudText Typo="Typo.body2" Class="mb-2 fp-ai-log-hint">
Jeder KI-Aufruf wird mit gesendetem Prompt und Antwort gespeichert, damit sich unbrauchbare Antworten nachvollziehen lassen. Fotos werden nur gezählt, nie gespeichert.
Der private KI-Chat wird nur mit Inhalt protokolliert, wenn das unter „Einstellungen → KI-Protokoll“ ausdrücklich eingeschaltet ist; sonst stehen dort nur Zeit, Person und Dauer.
Die Aufbewahrungsdauer wird ebenfalls in den Einstellungen festgelegt.
</MudText>
<div class="d-flex align-center flex-wrap gap-2 mb-2 fp-ai-log-filters">
<MudSelect T="AiPurpose?" Value="_purpose" ValueChanged="p => ApplyAsync(() => _purpose = p)" Label="Zweck" Variant="Variant.Outlined" Margin="Margin.Dense"
Clearable="true" Class="fp-ai-log-purpose" Style="min-width:12rem">
@foreach (var purpose in AiRules.SelectablePurposes)
{
<MudSelectItem T="AiPurpose?" Value="@((AiPurpose?)purpose)">@AiRules.PurposeName(purpose)</MudSelectItem>
}
</MudSelect>
<MudSelect T="bool?" Value="_success" ValueChanged="s => ApplyAsync(() => _success = s)" Label="Ergebnis" Variant="Variant.Outlined" Margin="Margin.Dense"
Clearable="true" Class="fp-ai-log-success" Style="min-width:10rem">
<MudSelectItem T="bool?" Value="@((bool?)true)">Erfolgreich</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)false)">Fehler</MudSelectItem>
</MudSelect>
<MudSelect T="Guid?" Value="_person" ValueChanged="p => ApplyAsync(() => _person = p)" Label="Person" Variant="Variant.Outlined" Margin="Margin.Dense"
Clearable="true" Class="fp-ai-log-person" Style="min-width:12rem">
@foreach (var person in _people)
{
<MudSelectItem T="Guid?" Value="@((Guid?)person.Id)">@person.Name</MudSelectItem>
}
</MudSelect>
<MudDatePicker Date="_from" DateChanged="d => ApplyAsync(() => _from = d)" Label="Von" Variant="Variant.Outlined" Margin="Margin.Dense" Clearable="true"
Class="fp-ai-log-from" Style="max-width:11rem" />
<MudDatePicker Date="_to" DateChanged="d => ApplyAsync(() => _to = d)" Label="Bis" Variant="Variant.Outlined" Margin="Margin.Dense" Clearable="true"
Class="fp-ai-log-to" Style="max-width:11rem" />
<MudSpacer />
<MudButton Color="Color.Error" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.DeleteSweep" OnClick="ClearAsync" Class="fp-ai-log-clear">Alle löschen</MudButton>
</div>
<MudTable T="AiExchangeListItemDto" @ref="_table" ServerData="LoadAsync" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm" Elevation="0" Outlined="true"
RowsPerPage="25" Class="fp-ai-log">
<HeaderContent>
<MudTh>Zeit</MudTh>
<MudTh>Person</MudTh>
<MudTh>Zweck</MudTh>
<MudTh>Anbieter</MudTh>
<MudTh>Ergebnis</MudTh>
<MudTh><span class="sr-only">Aktionen</span></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Zeit">@UserManagement.FormatDate(context.At)</MudTd>
<MudTd DataLabel="Person">@context.UserName</MudTd>
<MudTd DataLabel="Zweck">
@AiRules.PurposeName(context.Purpose)@(context.WebSearch ? " · Online-Suche" : string.Empty)@(context.ImageCount > 0 ? $" · {context.ImageCount} Bild(er)" : string.Empty)
@if (!context.ContentStored)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="fp-ai-log-nocontent">ohne Inhalt</MudChip>
}
</MudTd>
<MudTd DataLabel="Anbieter">@context.ProviderName (@context.Model)</MudTd>
<MudTd DataLabel="Ergebnis">
<span class="@(context.Success ? string.Empty : "fp-ai-failed")">@(context.Success ? "OK" : "Fehler") · @context.DurationMs ms</span>
</MudTd>
<MudTd Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Visibility" Size="Size.Small" OnClick="() => OpenAsync(context)" Class="fp-ai-log-open" aria-label="Aufruf ansehen" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => DeleteAsync(context)" Class="fp-ai-log-delete"
aria-label="Protokolleintrag löschen" />
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Class="fp-ai-log-empty">Keine Einträge.</MudText>
</NoRecordsContent>
<PagerContent>
<MudTablePager PageSizeOptions="@(new[] { 25, 50, 100 })" RowsPerPageString="Einträge pro Seite:" InfoFormat="{first_item}–{last_item} von {all_items}" />
</PagerContent>
</MudTable>
@code {
private MudTable<AiExchangeListItemDto>? _table;
private List<AiExchangePersonDto> _people = [];
private AiPurpose? _purpose;
private bool? _success;
private Guid? _person;
private DateTime? _from;
private DateTime? _to;
protected override async Task OnInitializedAsync() => _people = (await Api.GetExchangePeopleAsync()).Value ?? [];
/// <summary>Wird nach einem Aufruf des Admins (z. B. Verbindungstest) aufgerufen, damit der neue Eintrag erscheint.</summary>
public Task ReloadAsync() => _table is null ? Task.CompletedTask : _table.ReloadServerData();
private async Task ApplyAsync(Action change)
{
change();
if (_table is not null)
{
await _table.ReloadServerData();
}
}
private async Task<TableData<AiExchangeListItemDto>> LoadAsync(TableState state, CancellationToken ct)
{
var filter = new AiExchangeFilter(_purpose, _success, _person, ToDay(_from), ToDay(_to), state.Page + 1, state.PageSize);
var result = await Api.GetExchangesAsync(filter, ct);
return result.Value is { } page
? new TableData<AiExchangeListItemDto> { Items = page.Items, TotalItems = page.TotalCount }
: new TableData<AiExchangeListItemDto> { Items = [], TotalItems = 0 };
}
private static DateOnly? ToDay(DateTime? date) => date is { } d ? DateOnly.FromDateTime(d) : null;
private async Task OpenAsync(AiExchangeListItemDto item)
{
var parameters = new DialogParameters<AiExchangeDialog> { { d => d.Id, item.Id } };
await DialogService.ShowAsync<AiExchangeDialog>("KI-Aufruf", parameters,
new DialogOptions { MaxWidth = MaxWidth.Large, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
}
private async Task DeleteAsync(AiExchangeListItemDto item)
{
if (await DialogService.ShowMessageBoxAsync("Protokolleintrag löschen?", "Der Eintrag samt Prompt und Antwort wird endgültig gelöscht.",
yesText: "Löschen", cancelText: "Abbrechen") != true)
{
return;
}
var result = await Api.DeleteExchangeAsync(item.Id);
Snackbar.Add(result.Problem?.Message ?? "Eintrag gelöscht.", result.Succeeded ? Severity.Success : Severity.Error);
await ReloadAsync();
}
private async Task ClearAsync()
{
if (await DialogService.ShowMessageBoxAsync("Gesamtes KI-Protokoll löschen?", "Alle gespeicherten Prompts und Antworten werden endgültig gelöscht.",
yesText: "Alles löschen", cancelText: "Abbrechen") != true)
{
return;
}
var result = await Api.ClearExchangesAsync();
Snackbar.Add(result.Problem?.Message ?? "KI-Protokoll geleert.", result.Succeeded ? Severity.Success : Severity.Error);
_people = (await Api.GetExchangePeopleAsync()).Value ?? [];
await ReloadAsync();
}
}
@@ -54,7 +54,7 @@ else
}
<MudText Typo="Typo.caption">
Bilder @(context.IsHomeNetwork || context.AllowImages ? "erlaubt" : "gesperrt") · @(context.HasApiKey ? "Schlüssel gesetzt" : "kein Schlüssel") ·
@(context.DailyLimitPerUser == 0 ? "ohne Tageslimit" : $"{context.DailyLimitPerUser}/Tag")
@(context.DailyLimitPerUser == 0 ? "ohne Tageslimit" : $"{context.DailyLimitPerUser}/Tag")@(context.AllowWebSearch ? " · Online-Suche erlaubt" : string.Empty)
</MudText>
</MudTd>
<MudTd Style="text-align:right">
@@ -120,42 +120,14 @@ else
}
}
<MudText Typo="Typo.h6" Class="mt-6 mb-1">Diagnose: letzte Aufrufe</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
Zeigt den gesendeten Prompt und die rohe Antwort der KI (nur im Arbeitsspeicher, nach einem Neustart leer; der private KI-Chat wird nicht aufgezeichnet).
</MudText>
@if (_exchanges is not { Count: > 0 })
{
<MudText Typo="Typo.body2" Class="fp-ai-noexchanges">Noch keine Aufrufe seit dem Start.</MudText>
}
else
{
<MudExpansionPanels Class="fp-ai-exchanges">
@foreach (var exchange in _exchanges)
{
<MudExpansionPanel Class="fp-ai-exchange">
<TitleContent>
<span class="@(exchange.Success ? string.Empty : "fp-ai-failed")">
@UserManagement.FormatDate(exchange.At) · @AiRules.PurposeName(exchange.Purpose) · @exchange.ProviderName (@exchange.Model) · @(exchange.Success ? "OK" : "Fehler") · @exchange.DurationMs ms
</span>
</TitleContent>
<ChildContent>
<MudText Typo="Typo.subtitle2">Gesendeter Prompt</MudText>
<pre class="fp-ai-prompt" style="white-space:pre-wrap;word-break:break-word;margin:0 0 12px 0;">@exchange.Prompt</pre>
<MudText Typo="Typo.subtitle2">Antwort der KI@(exchange.Error is null ? string.Empty : $" – Fehler: {exchange.Error}")</MudText>
<pre class="fp-ai-response" style="white-space:pre-wrap;word-break:break-word;margin:0;">@(string.IsNullOrEmpty(exchange.Response) ? "(leer)" : exchange.Response)</pre>
</ChildContent>
</MudExpansionPanel>
}
</MudExpansionPanels>
}
<AiLogView @ref="_log" />
@code {
private sealed record TestOutcome(string Name, AiTestResultDto Result);
private List<AiProviderDto>? _providers;
private AiUsageOverviewDto? _usage;
private List<AiExchangeDto>? _exchanges;
private AiLogView? _log;
private TestOutcome? _testResult;
private Guid? _testing;
@@ -165,7 +137,6 @@ else
{
_providers = (await Api.GetProvidersAsync()).Value ?? [];
_usage = (await Api.GetUsageAsync()).Value;
_exchanges = (await Api.GetExchangesAsync()).Value;
}
private static string DescribePurposes(AiPurpose purposes) =>
@@ -203,6 +174,9 @@ else
_testing = null;
_testResult = new TestOutcome(provider.Name, result.Value ?? new AiTestResultDto(false, result.Problem?.Message ?? "Der Test ist fehlgeschlagen.", 0, null));
_usage = (await Api.GetUsageAsync()).Value ?? _usage;
_exchanges = (await Api.GetExchangesAsync()).Value ?? _exchanges;
if (_log is not null)
{
await _log.ReloadAsync();
}
}
}
@@ -58,6 +58,25 @@
<MudText Typo="Typo.caption">Heimnetz-Adresse: Bilder bleiben im Heimnetz, die Freigabe ist nicht nötig.</MudText>
}
@if (AiRules.SupportsWebSearch(_type))
{
<MudSwitch T="bool" @bind-Value="_allowWebSearch" Label="Online-Suche erlaubt" Color="Color.Warning" Class="fp-ai-websearch" />
<MudText Typo="Typo.caption" Class="fp-ai-websearch-hint">
Eltern können dann beim KI-Entwurf „Online recherchieren“ wählen. Die Suche führt der Anbieter aus; Suchbegriffe und Inhalte des Prompts verlassen dabei das Heimnetz.
Standard: aus.
</MudText>
}
else if (_type == AiProviderType.Ollama)
{
<MudText Typo="Typo.caption" Class="fp-ai-websearch-unsupported">Online-Suche: von Ollama nicht unterstützt (die Web-Suche von Ollama braucht einen Cloud-Schlüssel).</MudText>
}
else
{
<MudText Typo="Typo.caption" Class="fp-ai-websearch-builtin">
Online-Suche: Dienste wie Perplexity (Sonar) recherchieren immer selbst im Web – ein Schalter ist nicht nötig. Suchbegriffe verlassen das Heimnetz.
</MudText>
}
<div>
<MudText Typo="Typo.subtitle2" Class="mb-1">Einsatzzweck</MudText>
<MudText Typo="Typo.caption" Class="mb-1">Je Zweck genau ein Anbieter. Ohne eigene Wahl gilt der Standard-Anbieter (außer bei der Foto-Prüfung).</MudText>
@@ -99,6 +118,7 @@
private int _limit = AiRules.DefaultDailyLimit;
private bool _active = true;
private bool _allowImages;
private bool _allowWebSearch;
private AiPurpose _purposes;
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
private string? _generalError;
@@ -120,8 +140,8 @@
return;
}
(_name, _type, _baseUrl, _model, _timeout, _limit, _active, _allowImages, _purposes) =
(p.Name, p.Type, p.BaseUrl, p.Model, p.TimeoutSeconds, p.DailyLimitPerUser, p.IsActive, p.AllowImages, p.Purposes);
(_name, _type, _baseUrl, _model, _timeout, _limit, _active, _allowImages, _purposes, _allowWebSearch) =
(p.Name, p.Type, p.BaseUrl, p.Model, p.TimeoutSeconds, p.DailyLimitPerUser, p.IsActive, p.AllowImages, p.Purposes, p.AllowWebSearch);
}
private void OnTypeChanged(AiProviderType type)
@@ -147,7 +167,7 @@
_generalError = null;
var request = new SaveAiProviderRequest(
_name.Trim(), _type, string.IsNullOrWhiteSpace(_baseUrl) ? null : _baseUrl.Trim(), _model.Trim(),
string.IsNullOrEmpty(_apiKey) ? null : _apiKey.Trim(), _clearKey, _timeout, _active, _allowImages, _purposes, _limit, Provider?.Version);
string.IsNullOrEmpty(_apiKey) ? null : _apiKey.Trim(), _clearKey, _timeout, _active, _allowImages, _purposes, _limit, Provider?.Version, _allowWebSearch && AiRules.SupportsWebSearch(_type));
var validation = new SaveAiProviderRequestValidator().Validate(request);
_errors = validation.Errors
@@ -55,6 +55,14 @@ else
<MudSwitch T="bool" @bind-Value="_model.AiChatEnabled" Label="KI-Chat im Chat anbieten (Eltern)" Color="Color.Primary" id="ai-chat-enabled" />
<MudSwitch T="bool" @bind-Value="_model.AiChatChildrenAllowed" Label="Auch Kindern erlauben" Color="Color.Primary" Disabled="@(!_model.AiChatEnabled)" id="ai-chat-children" />
<MudText Typo="Typo.caption">Jede Person chattet allein mit der KI, niemand sonst sieht das Gespräch. Der Anbieter wird unter „KI“ für „KI-Chat“ gewählt; bei Cloud-Anbietern verlassen die Texte das Heimnetz.</MudText>
<MudText Typo="Typo.subtitle2" Class="mt-3">KI-Protokoll</MudText>
<MudNumericField T="int" @bind-Value="_model.AiLogRetentionDays" Label="KI-Protokoll aufbewahren (Tage)"
HelperText="0 = unbegrenzt aufbewahren" Min="0" Max="3650" Variant="Variant.Outlined" Margin="Margin.Dense" id="ai-log-retention" />
<MudSwitch T="bool" @bind-Value="_model.AiLogChatContent" Label="KI-Chat-Inhalte protokollieren" Color="Color.Warning" id="ai-log-chat-content" />
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-ai-log-privacy">
Datenschutz: Das KI-Protokoll zeigt Admins Prompts und Antworten. Der KI-Chat ist privat – ist dieser Schalter aus (Standard), wird dort nur gespeichert,
dass ein Aufruf stattfand (Zeit, Person, Dauer), nicht was geschrieben wurde. Wer ihn einschaltet, sollte alle Chat-Nutzer vorher darüber informieren.
</MudAlert>
<MudText Typo="Typo.subtitle2" Class="mt-3">Gäste</MudText>
<MudSwitch T="bool" @bind-Value="_model.GuestsCanEditShoppingList" Label="Gäste dürfen die Einkaufsliste ergänzen/abhaken" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_model.GuestChatEnabled" Label="Gäste dürfen den Familienchat nutzen" Color="Color.Primary" />
@@ -146,6 +154,10 @@ else
public bool AiChatChildrenAllowed { get; set; }
public int AiLogRetentionDays { get; set; } = 90;
public bool AiLogChatContent { get; set; }
public static SettingsModel From(FamilySettingsDto s) => new()
{
FamilyName = s.FamilyName,
@@ -160,11 +172,13 @@ else
LearningPhotoRetentionDays = s.LearningPhotoRetentionDays,
AiChatEnabled = s.AiChatEnabled,
AiChatChildrenAllowed = s.AiChatChildrenAllowed && s.AiChatEnabled,
AiLogRetentionDays = s.AiLogRetentionDays,
AiLogChatContent = s.AiLogChatContent,
};
public FamilySettingsDto ToDto() => new(
FamilyName.Trim(), TimeZoneId, MealPlanDays, MealTypes, TaskCategories,
ChorePointsEnabled, ChildTasksRequireApproval, GuestsCanEditShoppingList, GuestChatEnabled, LearningPhotoRetentionDays,
AiChatEnabled, AiChatEnabled && AiChatChildrenAllowed);
AiChatEnabled, AiChatEnabled && AiChatChildrenAllowed, AiLogRetentionDays, AiLogChatContent);
}
}
@@ -74,6 +74,15 @@
HelperText="Thema oder Stil – die KI behandelt das nur als Wunsch."
Error="@HasError("Extra")" ErrorText="@ErrorFor("Extra")" />
@if (WebSearchAvailable)
{
<MudCheckBox T="bool" @bind-Value="_webSearch" Label="Online recherchieren" Disabled="_busy" Class="fp-ai-websearch" />
<MudText Typo="Typo.caption" Class="fp-ai-websearch-hint">
Die KI darf im Web suchen. Dabei verlassen Suchbegriffe, Fach, Klasse und dein Zusatzwunsch das Heimnetz. Inhalte aus dem Web können falsch sein –
prüfe den Entwurf besonders gründlich.
</MudText>
}
<MudText Typo="Typo.body2" Class="fp-ai-hint">
Die KI erstellt einen <b>Entwurf</b>. Du prüfst und änderst ihn im Editor; das Kind sieht das Blatt erst, wenn du es zuweist.
</MudText>
@@ -156,6 +165,11 @@
[Parameter]
public bool IsCloud { get; set; }
/// <summary>Der Anbieter kann online recherchieren und ein Admin hat das erlaubt: Auswahl „Online recherchieren“ anzeigen (Standard: aus).</summary>
[Parameter]
public bool WebSearchAvailable { get; set; }
private bool _webSearch;
private string _subject = LearningSubjects.Math;
private int _gradeLevel = 3;
private Guid _target;
@@ -198,7 +212,7 @@
_error = null;
_errors = [];
var request = new CreateAiDraftRequest(_subject, _gradeLevel, _target, _count, _difficulty, string.IsNullOrWhiteSpace(_extra) ? null : _extra.Trim(), _mode);
var request = new CreateAiDraftRequest(_subject, _gradeLevel, _target, _count, _difficulty, string.IsNullOrWhiteSpace(_extra) ? null : _extra.Trim(), _mode, WebSearchAvailable && _webSearch);
// Sofortiges Feedback mit denselben Regeln wie die Api (verbindlich ist die serverseitige Prüfung).
foreach (var failure in new CreateAiDraftRequestValidator().Validate(request).Errors)
@@ -207,6 +207,7 @@
{
{ x => x.Children, _children },
{ x => x.IsCloud, _ai?.LearningGenerationIsCloud ?? false },
{ x => x.WebSearchAvailable, _ai?.LearningWebSearchAvailable ?? false },
};
var dialog = await DialogService.ShowAsync<AiDraftDialog>(
"Mit KI erstellen", parameters, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseOnEscapeKey = true });
+59 -5
View File
@@ -1,4 +1,5 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Ai;
namespace FamilyPlanner.Web.Client.Services;
@@ -20,9 +21,19 @@ public interface IAiApi
Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default);
/// <summary>Letzte KI-Aufrufe mit Prompt und Antwort (Diagnose für Admins).</summary>
Task<ApiResult<List<AiExchangeDto>>> GetExchangesAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<AiExchangeDto>>.Ok([]));
/// <summary>KI-Protokoll (Admin): Seite der Aufrufe, gefiltert. Standardimplementierungen für Testdoubles.</summary>
Task<ApiResult<PagedResult<AiExchangeListItemDto>>> GetExchangesAsync(AiExchangeFilter filter, CancellationToken ct = default) =>
Task.FromResult(ApiResult<PagedResult<AiExchangeListItemDto>>.Ok(new PagedResult<AiExchangeListItemDto>([], 0, filter.Page, filter.PageSize)));
Task<ApiResult<AiExchangeDetailDto>> GetExchangeAsync(Guid id, CancellationToken ct = default) =>
Task.FromResult(ApiResult<AiExchangeDetailDto>.Fail(ApiProblem.Offline));
Task<ApiResult<List<AiExchangePersonDto>>> GetExchangePeopleAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<AiExchangePersonDto>>.Ok([]));
Task<ApiResult<bool>> DeleteExchangeAsync(Guid id, CancellationToken ct = default) => Task.FromResult(ApiResult<bool>.Ok(true));
Task<ApiResult<bool>> ClearExchangesAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<bool>.Ok(true));
}
public sealed class AiApi(HttpClient http) : IAiApi
@@ -45,8 +56,51 @@ public sealed class AiApi(HttpClient http) : IAiApi
public Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default) =>
Send<AiUsageOverviewDto>(() => http.GetAsync(AiApiRoutes.Usage, ct), ct);
public Task<ApiResult<List<AiExchangeDto>>> GetExchangesAsync(CancellationToken ct = default) =>
Send<List<AiExchangeDto>>(() => http.GetAsync(AiApiRoutes.Exchanges, ct), ct);
public Task<ApiResult<PagedResult<AiExchangeListItemDto>>> GetExchangesAsync(AiExchangeFilter filter, CancellationToken ct = default) =>
Send<PagedResult<AiExchangeListItemDto>>(() => http.GetAsync(ExchangesUrl(filter), ct), ct);
public Task<ApiResult<AiExchangeDetailDto>> GetExchangeAsync(Guid id, CancellationToken ct = default) =>
Send<AiExchangeDetailDto>(() => http.GetAsync($"{AiApiRoutes.Exchanges}/{id}", ct), ct);
public Task<ApiResult<List<AiExchangePersonDto>>> GetExchangePeopleAsync(CancellationToken ct = default) =>
Send<List<AiExchangePersonDto>>(() => http.GetAsync(AiApiRoutes.ExchangePeople, ct), ct);
public Task<ApiResult<bool>> DeleteExchangeAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{AiApiRoutes.Exchanges}/{id}", ct), ct);
public Task<ApiResult<bool>> ClearExchangesAsync(CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync(AiApiRoutes.Exchanges, ct), ct);
internal static string ExchangesUrl(AiExchangeFilter filter)
{
var query = new List<string> { $"page={filter.Page}", $"pageSize={filter.PageSize}" };
if (filter.Purpose is { } purpose)
{
query.Add($"purpose={(int)purpose}");
}
if (filter.Success is { } success)
{
query.Add($"success={(success ? "true" : "false")}");
}
if (filter.UserId is { } user)
{
query.Add($"userId={user}");
}
if (filter.From is { } from)
{
query.Add($"from={from:yyyy-MM-dd}");
}
if (filter.To is { } to)
{
query.Add($"to={to:yyyy-MM-dd}");
}
return $"{AiApiRoutes.Exchanges}?{string.Join('&', query)}";
}
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) =>
Send<AiStatusDto>(() => http.GetAsync(AiApiRoutes.Status, ct), ct);