From faf6ec826e8ad31a803c9fa43d5673240d0f4cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 6 Jun 2026 10:41:48 +0200 Subject: [PATCH 1/5] feat(intelligence): implement Global AI Q&A screen and paywall blocker - Implemented standard empty and active chat conversation states for the `/intelligence` page - Created interactive `AiResponseRenderer` with AOT-compliant sentence splitting and payment gateway simulation - Added scoped `LibraryStateService` to synchronize book ownership and updates across the application - Obfuscated paywalled content in DOM to prevent inspection bypass - Fixed local port connection mismatch by updating API configurations to use port 5104 --- src/NexusReader.Maui/MauiProgram.cs | 3 +- src/NexusReader.Maui/appsettings.json | 2 +- .../Molecules/AiResponseRenderer.razor | 244 ++++++++++++ .../Molecules/AiResponseRenderer.razor.css | 269 +++++++++++++ .../Models/ReaderModels.cs | 6 + src/NexusReader.UI.Shared/Pages/Catalog.razor | 17 + .../Pages/Intelligence.razor | 240 ++++++++---- .../Pages/Intelligence.razor.css | 364 +++++++----------- src/NexusReader.UI.Shared/Pages/MyBooks.razor | 17 + .../Services/ILibraryStateService.cs | 12 + .../Services/LibraryStateService.cs | 27 ++ src/NexusReader.Web.Client/Program.cs | 1 + src/NexusReader.Web/Program.cs | 44 +++ src/NexusReader.Web/appsettings.Test.json | 2 +- src/NexusReader.Web/appsettings.json | 2 +- 15 files changed, 932 insertions(+), 318 deletions(-) create mode 100644 src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor create mode 100644 src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css create mode 100644 src/NexusReader.UI.Shared/Services/ILibraryStateService.cs create mode 100644 src/NexusReader.UI.Shared/Services/LibraryStateService.cs diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index a4b2c6c..4a5fca4 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -56,7 +56,7 @@ public static class MauiProgram builder.Services.AddTransient(); builder.Services.AddHttpClient("NexusAPI", client => { - var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000"; + var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5104"; client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); @@ -74,6 +74,7 @@ public static class MauiProgram builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusReader.Maui/appsettings.json b/src/NexusReader.Maui/appsettings.json index 4d3ef31..20260df 100644 --- a/src/NexusReader.Maui/appsettings.json +++ b/src/NexusReader.Maui/appsettings.json @@ -1,6 +1,6 @@ { "ApiSettings": { - "BaseUrl": "https://localhost:5000" + "BaseUrl": "http://localhost:5104" }, "Serilog": { "Using": [ diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor new file mode 100644 index 0000000..557d936 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor @@ -0,0 +1,244 @@ +@using NexusReader.UI.Shared.Models +@using NexusReader.UI.Shared.Services +@using NexusReader.Application.DTOs.AI +@using NexusReader.Application.DTOs.User +@using System.Net.Http.Json +@inject HttpClient Http +@inject ILibraryStateService LibraryStateService +@inject NavigationManager NavigationManager + +
+
+ @if (Message.Sender == "User") + { + + } + else + { + + } +
+ +
+
+ @Message.Sender + @Message.Timestamp.ToString("HH:mm") +
+ +
+ @if (Message.Sender == "User") + { +

@Message.Text

+ } + else + { + @if (Message.IsPaywalled && !_isUnlocked) + { +
+ @foreach (var segment in ParseSegments(Message.ClearText)) + { + @if (segment.IsCitation) + { + + } + else + { + @RenderMarkdown(segment.Text) + } + } +
+ +
+ @foreach (var segment in ParseSegments(Message.BlurredTeaserText)) + { + @if (segment.IsCitation) + { + + } + else + { + @RenderMarkdown(segment.Text) + } + } +
+ +
+
+ 🔒 +

Zablokowano pełną odpowiedź (Paywall)

+
+ +

+ Powyższy fragment wiedzy pochodzi z materiału: + '@(string.IsNullOrEmpty(Message.SourceBookTitle) ? "Architektura .NET 10 i Ekosystem Blazor" : Message.SourceBookTitle)'. + Ta pozycja nie znajduje się w Twojej bibliotece (/my-books). Aby uzyskać dostęp do pełnych instrukcji technicznych oraz gotowych kodów C#, odblokuj ten materiał w katalogu. +

+ +
+ @if (_isSimulatingPayment) + { + + } + else + { + + } + + Zobacz szczegóły w Katalogu + +
+
+ } + else + { +
+ @foreach (var segment in Message.Segments) + { + @if (segment.IsCitation) + { + + } + else + { + @RenderMarkdown(segment.Text) + } + } +
+ + @if (_showSuccessBanner) + { +
+ + Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki. +
+ } + } + } +
+
+
+ +@code { + [Parameter] public ChatMessage Message { get; set; } = default!; + [Parameter] public List? OwnedBooks { get; set; } + + private string GetBubbleClass() + { + if (Message.Sender == "User") return "user-bubble"; + return Message.IsPaywalled && !_isUnlocked ? "ai-bubble paywalled-bubble" : "ai-bubble"; + } + + private bool _isUnlocked = false; + private bool _isSimulatingPayment = false; + private bool _showSuccessBanner = false; + + 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(Message.SourceBookTitle) + ? "Architektura .NET 10 i Ekosystem Blazor" + : Message.SourceBookTitle; + + // Call POST endpoint to persist the purchase + var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle }); + if (response.IsSuccessStatusCode) + { + _isUnlocked = true; + Message.IsPaywalled = false; + _showSuccessBanner = true; + + // Fetch updated library list and update state manager + var updatedBooks = await Http.GetFromJsonAsync>("api/library/books"); + LibraryStateService.OwnedBooks = updatedBooks; + } + else + { + Console.WriteLine("[AiResponseRenderer] Purchase failed on server."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[AiResponseRenderer] Error processing purchase: {ex.Message}"); + } + finally + { + _isSimulatingPayment = false; + StateHasChanged(); + } + } + + private List ParseSegments(string text) + { + var segments = new List(); + 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, @"\*\*(.*?)\*\*", "$1"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "$1"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "
$1
"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "$1"); + html = html.Replace("\n", "
"); + + return new MarkupString(html); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css new file mode 100644 index 0000000..cc479c0 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css @@ -0,0 +1,269 @@ +.message-row { + display: flex; + gap: 1rem; + width: 100%; + max-width: 90%; + margin-bottom: 1.5rem; + animation: bubble-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.user-row { + align-self: flex-end; + margin-left: auto; + flex-direction: row-reverse; +} + +.ai-row { + align-self: flex-start; + margin-right: auto; +} + +.message-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + flex-shrink: 0; +} + +.user-row .message-avatar { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 0 10px rgba(255, 255, 255, 0.1); +} + +.ai-row .message-avatar { + background: linear-gradient(135deg, #005f38 0%, #004024 100%); + color: #e6fffa; + border: 1px solid rgba(0, 255, 153, 0.4); + box-shadow: 0 0 10px rgba(0, 255, 153, 0.25); +} + +.message-bubble { + padding: 1.25rem 1.5rem; + border-radius: 16px; + position: relative; + line-height: 1.6; + font-size: 0.975rem; + display: flex; + flex-direction: column; + width: 100%; +} + +.user-bubble { + background: #1a1a1e; + border: 1px solid rgba(255, 255, 255, 0.05); + color: #e4e4e7; + border-top-right-radius: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.ai-bubble { + background: rgba(26, 26, 30, 0.6); + border: 1px solid rgba(255, 255, 255, 0.05); + color: #e2e8f0; + border-top-left-radius: 4px; + box-shadow: 0 4px 25px rgba(0, 0, 0, 0.2); +} + +.paywalled-bubble { + border-color: rgba(16, 185, 129, 0.15); +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + font-size: 0.75rem; + opacity: 0.6; +} + +.sender-name { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.message-time { + font-family: monospace; +} + +.message-content { + word-break: break-word; +} + +/* Paragraph spacing */ +.message-content p { + margin: 0 0 1rem 0; +} +.message-content p:last-child { + margin-bottom: 0; +} + +/* Paywall Blur Styles */ +.teaser-blur { + position: relative; + filter: blur(5px); + pointer-events: none; + -webkit-user-select: none; + user-select: none; + opacity: 0.35; + margin-top: 1rem; + margin-bottom: 1.5rem; + -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 100%); + mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 100%); +} + +/* Upsell Card */ +.upsell-card { + background: #1a1a1e; + border-radius: 12px; + border: 1px solid rgba(16, 185, 129, 0.2); + padding: 1.5rem; + margin-top: 1rem; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); + animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.upsell-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.upsell-icon { + font-size: 1.25rem; +} + +.upsell-header h4 { + margin: 0; + color: #10b981; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0.5px; +} + +.upsell-text { + color: rgba(255, 255, 255, 0.75); + font-size: 0.9rem; + line-height: 1.55; + margin: 0 0 1.25rem 0; +} + +.upsell-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.btn-upsell { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + border-radius: 8px; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + text-decoration: none; + letter-spacing: 0.5px; + min-height: 44px; +} + +.btn-primary { + background: #10b981; + border: none; + color: #121214; +} + +.btn-primary:hover:not(:disabled) { + background: #0d9668; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3); +} + +.btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +.btn-primary:disabled { + background: rgba(16, 185, 129, 0.5); + color: rgba(18, 18, 20, 0.6); + cursor: not-allowed; +} + +.btn-secondary { + background: transparent; + border: 1px solid #10b981; + color: #10b981; +} + +.btn-secondary:hover { + background: rgba(16, 185, 129, 0.05); + transform: translateY(-2px); +} + +.btn-secondary:active { + transform: translateY(0); +} + +/* Success Banner */ +.success-unlock-banner { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); + color: #10b981; + padding: 1rem; + border-radius: 8px; + margin-top: 1.25rem; + font-size: 0.9rem; + font-weight: 600; + animation: fade-in 0.5s ease-out; +} + +.success-icon { + font-weight: bold; + font-size: 1.1rem; +} + +/* Payment Spinner */ +.payment-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(18, 18, 20, 0.2); + border-top-color: #121214; + border-radius: 50%; + margin-right: 0.75rem; + animation: spin 0.8s linear infinite; +} + +/* Keyframes */ +@keyframes bubble-fade-in { + 0% { opacity: 0; transform: translateY(12px) scale(0.98); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes card-slide-in { + 0% { opacity: 0; transform: translateY(10px); } + 100% { opacity: 1; transform: translateY(0); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/src/NexusReader.UI.Shared/Models/ReaderModels.cs b/src/NexusReader.UI.Shared/Models/ReaderModels.cs index 9340821..bcebc18 100644 --- a/src/NexusReader.UI.Shared/Models/ReaderModels.cs +++ b/src/NexusReader.UI.Shared/Models/ReaderModels.cs @@ -28,6 +28,12 @@ public class ChatMessage public DateTime Timestamp { get; set; } = DateTime.UtcNow; public List Segments { get; set; } = new(); public List Citations { get; set; } = new(); + + public string ClearText { get; set; } = string.Empty; + public string BlurredTeaserText { get; set; } = string.Empty; + public bool IsPaywalled { get; set; } + public string SourceBookTitle { get; set; } = string.Empty; + public string DocumentId { get; set; } = string.Empty; } /// diff --git a/src/NexusReader.UI.Shared/Pages/Catalog.razor b/src/NexusReader.UI.Shared/Pages/Catalog.razor index 09eca8b..e4255bd 100644 --- a/src/NexusReader.UI.Shared/Pages/Catalog.razor +++ b/src/NexusReader.UI.Shared/Pages/Catalog.razor @@ -1,5 +1,6 @@ @page "/catalog" @attribute [Authorize] +@implements IDisposable @using NexusReader.UI.Shared.Components.Organisms @using NexusReader.Application.DTOs.User @using NexusReader.UI.Shared.Services @@ -7,6 +8,7 @@ @inject HttpClient Http @inject IReaderNavigationService ReaderNavigation @inject NavigationManager NavigationManager +@inject ILibraryStateService LibraryStateService
@@ -189,6 +191,11 @@ private bool _isLoading = true; private List? _books; + protected override void OnInitialized() + { + LibraryStateService.OnBooksChanged += HandleBooksChanged; + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) @@ -197,6 +204,11 @@ } } + private void HandleBooksChanged() + { + _ = InvokeAsync(LoadBooksAsync); + } + private async Task LoadBooksAsync() { _isLoading = true; @@ -231,4 +243,9 @@ // Showcase callback NavigationManager.NavigateTo("/profile"); } + + public void Dispose() + { + LibraryStateService.OnBooksChanged -= HandleBooksChanged; + } } diff --git a/src/NexusReader.UI.Shared/Pages/Intelligence.razor b/src/NexusReader.UI.Shared/Pages/Intelligence.razor index 8e7e432..fca1618 100644 --- a/src/NexusReader.UI.Shared/Pages/Intelligence.razor +++ b/src/NexusReader.UI.Shared/Pages/Intelligence.razor @@ -1,35 +1,34 @@ @page "/intelligence" @attribute [Authorize] +@implements IDisposable @using NexusReader.Application.DTOs.AI @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.DTOs.User +@using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Models @using System.Net.Http.Json @inject HttpClient Http @inject IKnowledgeService KnowledgeService @inject AuthenticationStateProvider AuthStateProvider +@inject ILibraryStateService LibraryStateService
-
-
-

Global Intelligence

-

Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG

-
-
- -
+
@if (_chatMessages.Count == 0) {
- - + + + + + +
-

Start Interrogating Your Library

-

Ask complex questions across your entire ebook collection. The KM-RAG engine dynamically builds semantic maps, resolves dependencies, and formulates high-fidelity, grounded answers with interactive popover citations.

+
Zadaj pytanie globalne do całej biblioteki...
} else @@ -37,37 +36,7 @@
@foreach (var message in _chatMessages) { -
-
- @if (message.Sender == "User") - { - - } - else - { - - } -
-
-
- @message.Sender - @message.Timestamp.ToString("HH:mm") -
-
- @foreach (var segment in message.Segments) - { - @if (segment.IsCitation) - { - - } - else - { - @RenderMarkdown(segment.Text) - } - } -
-
-
+ } @if (_isLoading) @@ -100,9 +69,9 @@
- +