340 lines
12 KiB
Text
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());
|
|
}
|
|
}
|