ce4687ee93
- 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
280 lines
10 KiB
Plaintext
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);
|
|
}
|
|
}
|