Files
Nexus.Reader/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor
T
mjasin ce4687ee93 fix: resolve PR #76 review recommendations
- Add ILogger<GetContextualRecommendationsQueryHandler> with structured logging
- Guard empty embedding text in VectorSearchStore (return empty vector, skip search)
- Benchmark vector search and embedding latency with Stopwatch (LogDebug/LogInfo)
- Refine EnsureCollectionExistsAsync: log creation events and non-fatal errors
- Replace all Console.WriteLine with ILogger in UI components (AiAssistantBubble,
  AiResponseRenderer, IntelligenceToolbar, SelectionAiPanel, Catalog, Intelligence, MyBooks)
- Create IRecommendationService abstraction + RecommendationService WASM implementation
- Register IRecommendationService in Web.Client DI
- Add ContextualRecommendationsWidget component with loading spinner and design tokens
- Add ContextualRecommendationsWidget to Dashboard.razor
- Update test constructor with ILogger mock for GetContextualRecommendationsQueryHandler

Closes review items: 2, 3, 4, 5, 6, 7, 8, 9, 10
Item 1 (unit tests) was already completed in previous session
2026-06-06 14:54:44 +02:00

280 lines
10 KiB
Plaintext

@using NexusReader.UI.Shared.Models
@using NexusReader.UI.Shared.Services
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.DTOs.User
@using Microsoft.Extensions.Logging
@using System.Net.Http.Json
@inject HttpClient Http
@inject ILibraryStateService LibraryStateService
@inject NavigationManager NavigationManager
@inject ILogger<AiResponseRenderer> Logger
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
<div class="message-avatar" aria-hidden="true">
@if (Message.Sender == "User")
{
<i class="bi bi-person-fill"></i>
}
else
{
<i class="bi bi-robot"></i>
}
</div>
<div class="message-bubble @GetBubbleClass()">
<div class="message-header">
<span class="sender-name">@Message.Sender</span>
<span class="message-time">@Message.Timestamp.ToString("HH:mm")</span>
</div>
<div class="message-content">
@if (Message.Sender == "User")
{
<p>@Message.Text</p>
}
else
{
@if (_hasPaywall)
{
<div class="paywall-teaser" aria-hidden="true">
@foreach (var segment in ParseSegments(_displayTeaserText))
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
<div class="upsell-card" role="alert" aria-live="polite">
<div class="upsell-header">
<span class="upsell-icon" aria-hidden="true">🔒</span>
<h4>Dostęp Premium Zablokowany</h4>
</div>
<p class="upsell-text">
Twoje zasoby odpowiadają na to pytanie w <strong>@_localScore%</strong>. W materiale <strong>'@_lockedBookTitle'</strong> znaleźliśmy odpowiedź dopasowaną w <strong>@_globalScore%</strong>.
</p>
<div class="upsell-actions">
@if (_isSimulatingPayment)
{
<button class="btn-upsell btn-primary loading" disabled aria-busy="true">
<div class="payment-spinner" aria-hidden="true"></div>
PRZETWARZANIE PŁATNOŚCI...
</button>
}
else
{
<button class="btn-upsell btn-primary" @onclick="HandlePurchase">
ODBLOKUJ PEŁNĄ TREŚĆ (29 PLN)
</button>
}
<a href="/catalog?bookId=@_lockedBookId" class="btn-upsell btn-secondary">
Zobacz szczegóły w Katalogu
</a>
</div>
</div>
}
else
{
<div class="full-response">
@foreach (var segment in ParseSegments(GetCleanText()))
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
@if (_showSuccessBanner)
{
<div class="success-unlock-banner" role="status">
<span class="success-icon" aria-hidden="true">✓</span>
<span>Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.</span>
</div>
}
}
}
</div>
</div>
</div>
@code {
[Parameter] public ChatMessage Message { get; set; } = default!;
[Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; }
[Parameter] public EventCallback<Guid> OnUnlockRequested { get; set; }
private bool _hasPaywall;
private string _displayTeaserText = string.Empty;
private Guid _lockedBookId;
private string _lockedBookTitle = string.Empty;
private int _localScore;
private int _globalScore;
private bool _isUnlocked = false;
private bool _isSimulatingPayment = false;
private bool _showSuccessBanner = false;
protected override void OnParametersSet()
{
base.OnParametersSet();
if (Message != null && Message.Sender != "User" && !_isUnlocked)
{
_hasPaywall = PaywallParser.TryParsePaywallTrigger(Message.Text, out _displayTeaserText, out _lockedBookId, out _lockedBookTitle, out _localScore, out _globalScore);
// Additional check: if user already owns the book, don't show the paywall
if (_hasPaywall && OwnedBooks != null)
{
var isOwned = OwnedBooks.Any(b =>
b.Id == _lockedBookId ||
(!string.IsNullOrEmpty(b.Title) && b.Title.Equals(_lockedBookTitle, StringComparison.OrdinalIgnoreCase)));
if (isOwned)
{
_hasPaywall = false;
}
}
}
else
{
_hasPaywall = false;
}
}
private string GetCleanText()
{
if (Message == null) return string.Empty;
if (PaywallParser.TryParsePaywallTrigger(Message.Text, out var cleanText, out _, out _, out _, out _))
{
return cleanText;
}
return Message.Text;
}
private string GetBubbleClass()
{
if (Message.Sender == "User") return "user-bubble";
return _hasPaywall ? "ai-bubble paywalled-bubble" : "ai-bubble";
}
private async Task HandlePurchase()
{
if (_isSimulatingPayment) return;
_isSimulatingPayment = true;
StateHasChanged();
// Simulate payment gateway delay (1.5 seconds)
await Task.Delay(1500);
try
{
var bookTitle = string.IsNullOrEmpty(_lockedBookTitle)
? "Architektura .NET 10 i Ekosystem Blazor"
: _lockedBookTitle;
// Call POST endpoint to persist the purchase
var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle });
if (response.IsSuccessStatusCode)
{
_isUnlocked = true;
_hasPaywall = false;
_showSuccessBanner = true;
// Fetch updated library list and update state manager
var updatedBooks = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
LibraryStateService.OwnedBooks = updatedBooks;
if (OnUnlockRequested.HasDelegate)
{
await OnUnlockRequested.InvokeAsync(_lockedBookId);
}
}
else
{
Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId);
}
finally
{
_isSimulatingPayment = false;
StateHasChanged();
}
}
private List<ResponseSegment> ParseSegments(string text)
{
var segments = new List<ResponseSegment>();
if (string.IsNullOrEmpty(text)) return segments;
var regex = new System.Text.RegularExpressions.Regex(
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var matches = regex.Matches(text);
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in matches)
{
if (match.Index > lastIndex)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex, match.Index - lastIndex),
IsCitation = false
});
}
var citationId = match.Groups[1].Success
? match.Groups[1].Value.Trim()
: match.Groups[2].Value.Trim();
segments.Add(new ResponseSegment
{
IsCitation = true,
CitationId = citationId
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex),
IsCitation = false
});
}
return segments;
}
private MarkupString RenderMarkdown(string text)
{
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
var html = System.Net.WebUtility.HtmlEncode(text);
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
html = html.Replace("\n", "<br />");
return new MarkupString(html);
}
}