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:
1 parent
e0fdfbbdda
commit
bf5a1b9a89
9 files changed
+336
-44
No files matched your search
+1
-1
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user