AuthorBuddy/Components/Pages/Chat.razor
2026-08-30 08:11:01 +02:00

340 lines
12 KiB
Text

@page "/chat"
@attribute [Authorize(Roles = "Administrator,Chat")]
@inject IChatService ChatService
@inject IOllamaService OllamaService
@inject OllamaSettings Settings
@inject ISettingsService SettingsService
@inject IAuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@inject IFactCheckService FactCheckService
@rendermode InteractiveServer
<PageTitle>Chat</PageTitle>
<div class="chat-page">
<div class="chat-header">
<h3>Chat</h3>
<div class="chat-header-actions">
<button class="btn btn-sm chat-settings-toggle" @onclick="ToggleSettings">
@(_showSettings ? "Einstellungen ausblenden" : "Einstellungen")
</button>
<button class="btn btn-sm" @onclick="ClearHistory" disabled="@(_messages.Count == 0)">Verlauf löschen</button>
</div>
</div>
@if (_showSettings)
{
<div class="chat-settings">
<div class="form-group">
<label>System-Prompt</label>
<textarea class="chat-settings-textarea" @bind="_systemPrompt" rows="3"></textarea>
</div>
<div class="form-group">
<label for="chatTemp">Temperatur: @_temperature.ToString("F2")</label>
<input id="chatTemp" type="range" min="0" max="2" step="0.05" @bind="_temperature" @bind:event="oninput" />
<div class="qm-range-labels">
<span>präzise (0)</span>
<span>kreativ (2)</span>
</div>
</div>
<div class="form-group">
<label class="factcheck-toggle">
<input type="checkbox" @bind="_factCheckEnabled" />
Antwort automatisch per DuckDuckGo verifizieren
</label>
</div>
<div class="form-group">
<button class="btn btn-sm btn-primary" @onclick="SaveSettings">System-Prompt & Temperatur speichern</button>
<button class="btn btn-sm" @onclick="ResetToDefault">Auf Standard zurücksetzen</button>
</div>
</div>
}
<div class="chat-messages" id="chat-messages">
@if (_messages.Count == 0)
{
<div class="chat-empty">Noch keine Nachrichten. Stelle eine Frage.</div>
}
@for (var i = 0; i < _messages.Count; i++)
{
var msg = _messages[i];
var checks = _factChecks.Count > i ? _factChecks[i] : null;
<div class="chat-message chat-user">
<div class="chat-avatar">U</div>
<div class="chat-bubble">@msg.UserMessage</div>
</div>
<div class="chat-message chat-assistant">
<div class="chat-avatar">A</div>
<div class="chat-bubble">
@RenderAssistantMessage(msg.AssistantResponse)
@if (checks != null && checks.Count > 0)
{
<div class="factcheck-section">
<div class="factcheck-title">Faktenprüfung</div>
@foreach (var check in checks)
{
@RenderFactCheck(check)
}
</div>
}
</div>
</div>
}
@if (_isBusy)
{
<div class="chat-message chat-assistant">
<div class="chat-avatar">A</div>
<div class="chat-bubble">@RenderAssistantMessage(_streamingResponse, true)</div>
</div>
}
</div>
<div class="chat-input">
<textarea class="chat-textarea" @bind="_input" @bind:event="oninput" rows="3"
placeholder="Nachricht eingeben…"
@onkeydown="HandleKeyDown"
disabled="@_isBusy"></textarea>
<button class="btn btn-primary" @onclick="Send" disabled="@_sendDisabled">
Senden
</button>
</div>
@if (!string.IsNullOrEmpty(_error))
{
<div class="auth-error">@_error</div>
}
</div>
@code {
private List<ChatEntity> _messages = new();
private string _input = string.Empty;
private string _error = string.Empty;
private bool _isBusy;
private int _userId;
private bool _showSettings;
private bool _sendDisabled => _isBusy || string.IsNullOrWhiteSpace(_input);
private string _systemPrompt = string.Empty;
private double _temperature = 0.7;
private bool _needsScroll;
private string _streamingResponse = string.Empty;
private bool _factCheckEnabled;
private List<List<FactCheckResult>?> _factChecks = new();
protected override async Task OnInitializedAsync()
{
var user = await AuthService.GetCurrentUserAsync();
if (user == null || (!user.HasRole(UserRole.Administrator) && !user.HasRole(UserRole.Chat)))
{
Navigation.NavigateTo("/");
return;
}
_userId = user.Id;
_messages = await ChatService.GetHistoryAsync(_userId);
_factChecks = _messages.Select(_ => (List<FactCheckResult>?)null).ToList();
_systemPrompt = await SettingsService.GetChatPromptAsync()
?? Settings.GetChatDefaultPrompt(Settings.Language);
_temperature = await SettingsService.GetChatTempAsync();
_factCheckEnabled = await SettingsService.GetChatFactCheckEnabledAsync();
_needsScroll = true;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_needsScroll)
{
_needsScroll = false;
await ScrollToBottom();
}
}
private void ToggleSettings()
{
_showSettings = !_showSettings;
}
private async Task SaveSettings()
{
await SettingsService.SaveChatPromptAsync(_systemPrompt);
await SettingsService.SaveChatTempAsync(_temperature);
await SettingsService.SaveChatFactCheckEnabledAsync(_factCheckEnabled);
}
private async Task ResetToDefault()
{
_systemPrompt = Settings.GetChatDefaultPrompt(Settings.Language);
_temperature = Settings.ChatTemp;
await SaveSettings();
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !e.ShiftKey)
{
await Send();
}
}
private async Task Send()
{
if (string.IsNullOrWhiteSpace(_input) || _isBusy) return;
var text = _input.Trim();
_input = string.Empty;
_error = string.Empty;
_isBusy = true;
_streamingResponse = string.Empty;
_needsScroll = true;
try
{
var model = await SettingsService.GetStyleModelAsync() ?? "llama3.2";
await OllamaService.ChatStream(model, text, async chunk =>
{
_streamingResponse += chunk;
_needsScroll = true;
await InvokeAsync(StateHasChanged);
}, _systemPrompt, _temperature, Settings.StyleBackend);
var msg = new ChatEntity
{
UserId = _userId,
UserMessage = text,
AssistantResponse = _streamingResponse,
Model = model,
Timestamp = DateTime.UtcNow
};
await ChatService.AddMessageAsync(msg);
_messages.Add(msg);
if (_factCheckEnabled && !string.IsNullOrWhiteSpace(_streamingResponse))
{
_needsScroll = true;
_factChecks.Add(await FactCheckService.VerifyAsync(_streamingResponse, Settings.StyleBackend));
}
else
{
_factChecks.Add(null);
}
_needsScroll = true;
}
catch (Exception ex)
{
_error = $"Fehler: {ex.Message}";
}
finally
{
_isBusy = false;
_needsScroll = true;
}
}
private async Task ClearHistory()
{
await ChatService.ClearHistoryAsync(_userId);
_messages.Clear();
}
private async Task ScrollToBottom()
{
await JS.InvokeVoidAsync("eval", "document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight");
}
private static (string thinking, string answer) ParseResponse(string text)
{
if (string.IsNullOrEmpty(text)) return ("", "");
var closeTag = "</thinking>";
var closeIdx = text.IndexOf(closeTag, StringComparison.Ordinal);
if (closeIdx >= 0)
{
var openTag = "<thinking>";
var openIdx = text.IndexOf(openTag, StringComparison.Ordinal);
if (openIdx >= 0 && openIdx < closeIdx)
{
var thinking = text[(openIdx + openTag.Length)..closeIdx].Trim();
var answer = text[(closeIdx + closeTag.Length)..].Trim();
return (thinking, answer);
}
}
var lastOpenIdx = text.LastIndexOf("<thinking>", StringComparison.Ordinal);
if (lastOpenIdx >= 0)
return (text[(lastOpenIdx + "<thinking>".Length)..], "");
return ("", text);
}
private MarkupString RenderAssistantMessage(string text, bool isStreaming = false)
{
if (string.IsNullOrEmpty(text))
return new MarkupString(isStreaming ? "<span class=\"chat-thinking\">…</span>" : string.Empty);
var (thinking, answer) = ParseResponse(text);
var html = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(thinking))
{
html.Append("<details class=\"thinking-block\"");
if (isStreaming || !string.IsNullOrEmpty(answer))
html.Append(" open");
html.Append("><summary>Gedankengang</summary><div class=\"thinking-content\">");
html.Append(System.Web.HttpUtility.HtmlEncode(thinking));
html.Append("</div></details>");
}
if (!string.IsNullOrEmpty(answer))
{
var encoded = System.Web.HttpUtility.HtmlEncode(answer);
encoded = System.Text.RegularExpressions.Regex.Replace(encoded, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
encoded = System.Text.RegularExpressions.Regex.Replace(encoded, @"\*(.+?)\*", "<em>$1</em>");
encoded = encoded.Replace("\n", "<br />");
html.Append(encoded);
}
else if (string.IsNullOrEmpty(thinking))
{
html.Append("<span class=\"chat-thinking\">…</span>");
}
return new MarkupString(html.ToString());
}
private MarkupString RenderFactCheck(FactCheckResult check)
{
var (icon, label, cssClass) = check.Verdict switch
{
FactCheckVerdict.Confirmed => ("✓", "Bestätigt", "verdict-confirmed"),
FactCheckVerdict.Contradicted => ("✗", "Widersprochen", "verdict-contradicted"),
FactCheckVerdict.Partial => ("⚠", "Teilweise bestätigt", "verdict-partial"),
_ => ("?", "Keine Information", "verdict-none")
};
var html = new System.Text.StringBuilder();
html.Append("<div class=\"factcheck-item\">");
html.Append($"<div class=\"factcheck-header\"><span class=\"factcheck-verdict {cssClass}\">{icon} {label}</span></div>");
html.Append("<div class=\"factcheck-claim\">");
html.Append(System.Web.HttpUtility.HtmlEncode(check.Claim));
html.Append("</div>");
if (!string.IsNullOrEmpty(check.Reasoning))
{
html.Append("<div class=\"factcheck-reasoning\">");
html.Append(System.Web.HttpUtility.HtmlEncode(check.Reasoning));
html.Append("</div>");
}
if (check.Sources.Count > 0)
{
html.Append("<div class=\"factcheck-sources\">");
foreach (var source in check.Sources)
{
html.Append("<div class=\"factcheck-source\">");
html.Append(System.Web.HttpUtility.HtmlEncode(source));
html.Append("</div>");
}
html.Append("</div>");
}
html.Append("</div>");
return new MarkupString(html.ToString());
}
}