feat(recommendations): implement contextual recommendation engine (#76)
Resolves #75 ### Description This pull request implements a smart, Native AOT-compliant contextual recommendation engine for the desktop dashboard to drive user retention and cross-book monetization. ### Key Changes 1. **Application Layer**: - Declared `IUserReadingStateStore` interface to decouple reading state discovery. - Added `IVectorSearchStore.SearchGlobalExcludeAsync(...)` to abstract semantic similarity searches with exclusions. - Defined `GetContextualRecommendationsQuery` and response DTOs (`ContextualRecommendationResponse`, `RecommendationDto`). 2. **Infrastructure Layer**: - Implemented `UserReadingStateStore` using EF Core with DbContext pooling. - Implemented `SearchGlobalExcludeAsync` in `VectorSearchStore` to construct gRPC Qdrant filters (excluding the active book ID) and fetch `bookTitle` and `chapterTitle` from point payloads. - Implemented `GetContextualRecommendationsQueryHandler` using clean abstractions. 3. **Web & Serialization Layer**: - Mapped `GET /api/recommendations` endpoint. - Registered types in `AppJsonContext.cs` for AOT-compliant JSON serialization. 4. **Verification**: - Added complete unit test coverage in `GetContextualRecommendationsQueryTests.cs`. All 30 unit tests pass. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #76 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #76.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject IQuizStateService QuizState
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject ILogger<AiAssistantBubble> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<div class="ai-bubble-container">
|
||||
@@ -134,7 +136,7 @@
|
||||
catch (Exception ex)
|
||||
{
|
||||
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
|
||||
Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}");
|
||||
Logger.LogError(ex, "[AiAssistantBubble] Error fetching summary for block {BlockId}.", ContextBlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
.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 */
|
||||
.paywall-teaser {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 30%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 30%, transparent 100%);
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Upsell Card */
|
||||
.upsell-card {
|
||||
background: #1a1a1e;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
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); }
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Linq
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IThemeService ThemeService
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject ILogger<IntelligenceToolbar> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<aside class="intelligence-toolbar">
|
||||
@@ -51,11 +54,15 @@
|
||||
|
||||
private async Task HandleClearCache()
|
||||
{
|
||||
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
|
||||
Logger.LogInformation("[IntelligenceToolbar] Requesting cache clear...");
|
||||
var result = await KnowledgeService.ClearCacheAsync();
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
|
||||
Logger.LogInformation("[IntelligenceToolbar] Cache cleared successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("[IntelligenceToolbar] Cache clear failed: {Errors}", string.Join("; ", result.Errors.Select(e => e.Message)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.UI.Shared.Models
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject IQuizStateService QuizService
|
||||
@inject IJSRuntime JS
|
||||
@inject ILogger<SelectionAiPanel> Logger
|
||||
|
||||
@if (IsVisible)
|
||||
{
|
||||
@@ -64,7 +66,7 @@
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}");
|
||||
Logger.LogDebug("[SelectionAiPanel] Parameters set. SelectedText: {Length} chars, Coordinates: {Top}", SelectedText.Length, Coordinates?.Top);
|
||||
|
||||
if (Coordinates != _lastCoordinates)
|
||||
{
|
||||
@@ -100,7 +102,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error positioning toolbar: {ex.Message}");
|
||||
Logger.LogWarning(ex, "[SelectionAiPanel] Error positioning toolbar.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +135,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error requesting summary: {ex.Message}");
|
||||
Logger.LogError(ex, "[SelectionAiPanel] Error requesting summary for block {BlockId}.", BlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -173,7 +175,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error generating quiz: {ex.Message}");
|
||||
Logger.LogError(ex, "[SelectionAiPanel] Error generating quiz for block {BlockId}.", BlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject IRecommendationService RecommendationService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ContextualRecommendationsWidget> Logger
|
||||
|
||||
<section class="recommendations-panel glass-panel" aria-label="Kontekstowe rekomendacje">
|
||||
<div class="panel-header">
|
||||
<div class="header-left">
|
||||
<NexusIcon Name="sparkles" Size="18" />
|
||||
<h4>Odkryj Więcej</h4>
|
||||
</div>
|
||||
<span class="panel-badge">AI</span>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="loading-state" role="status" aria-label="Ładowanie rekomendacji">
|
||||
<div class="spinner-ring">
|
||||
<div class="spinner-track"></div>
|
||||
<div class="spinner-head"></div>
|
||||
</div>
|
||||
<span class="loading-label">Analizowanie kontekstu lektury…</span>
|
||||
</div>
|
||||
}
|
||||
else if (_hasError)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<NexusIcon Name="alert-circle" Size="32" />
|
||||
<p>Nie udało się załadować rekomendacji.</p>
|
||||
</div>
|
||||
}
|
||||
else if (_recommendations is null || _recommendations.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<NexusIcon Name="book-open" Size="32" />
|
||||
<p>Zacznij czytać, aby odkryć powiązane tytuły.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="recommendations-list" role="list">
|
||||
@foreach (var rec in _recommendations)
|
||||
{
|
||||
<li class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")"
|
||||
role="listitem">
|
||||
<div class="rec-content">
|
||||
<div class="rec-meta">
|
||||
<span class="match-badge" title="Dopasowanie semantyczne @rec.MatchPercentage%">
|
||||
@rec.MatchPercentage<span class="match-unit">%</span>
|
||||
</span>
|
||||
@if (rec.IsPremiumUpsell)
|
||||
{
|
||||
<span class="upsell-tag" aria-label="Książka premium">Premium</span>
|
||||
}
|
||||
</div>
|
||||
<p class="rec-book-title">@rec.BookTitle</p>
|
||||
<p class="rec-chapter-title">@rec.ChapterTitle</p>
|
||||
</div>
|
||||
<button class="rec-action-btn"
|
||||
@onclick="() => HandleRecommendationClick(rec)"
|
||||
aria-label="@(rec.IsPremiumUpsell ? "Kup " + rec.BookTitle : "Przejdź do " + rec.BookTitle)">
|
||||
<NexusIcon Name="@(rec.IsPremiumUpsell ? "shopping-cart" : "arrow-right")" Size="16" />
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private List<RecommendationDto>? _recommendations;
|
||||
private bool _isLoading = true;
|
||||
private bool _hasError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadRecommendationsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadRecommendationsAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
_hasError = false;
|
||||
|
||||
try
|
||||
{
|
||||
_recommendations = await RecommendationService.GetRecommendationsAsync();
|
||||
if (_recommendations is null)
|
||||
{
|
||||
_hasError = true;
|
||||
Logger.LogWarning("[ContextualRecommendationsWidget] RecommendationService returned null; displaying error state.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRecommendationClick(RecommendationDto rec)
|
||||
{
|
||||
if (rec.IsPremiumUpsell)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/catalog?highlight={rec.TargetBookId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
NavigationManager.NavigateTo($"/reader/{rec.TargetBookId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
/* ContextualRecommendationsWidget.razor.css
|
||||
Uses Nexus Design System tokens (--nexus-*) for consistency.
|
||||
*/
|
||||
|
||||
.recommendations-panel {
|
||||
width: 100%;
|
||||
padding: 1.75rem;
|
||||
background: var(--nexus-surface, #1a1a1e);
|
||||
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.recommendations-panel:hover {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* ── Panel Header ── */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nexus-accent, #10b981);
|
||||
}
|
||||
|
||||
.header-left h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--nexus-text-primary, #ffffff);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.panel-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 100px;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(59, 130, 246, 0.1));
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: var(--nexus-accent, #10b981);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Loading State ── */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.spinner-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.spinner-head {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: var(--nexus-accent, #10b981);
|
||||
animation: nexus-spin 0.8s linear infinite;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
@keyframes nexus-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Empty / Error State ── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Recommendations List ── */
|
||||
.recommendations-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.recommendation-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.recommendation-item.premium {
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.recommendation-item.premium:hover {
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
background: rgba(245, 158, 11, 0.04);
|
||||
}
|
||||
|
||||
.recommendation-item.owned {
|
||||
border-color: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.recommendation-item.owned:hover {
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
/* ── Rec Content ── */
|
||||
.rec-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.rec-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.match-badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--nexus-accent, #10b981);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
.match-unit {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upsell-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rec-book-title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--nexus-text-primary, #ffffff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rec-chapter-title {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Action Button ── */
|
||||
.rec-action-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.rec-action-btn:hover {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: var(--nexus-accent, #10b981);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.premium .rec-action-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recommendations-panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,12 @@ public class ChatMessage
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public List<ResponseSegment> Segments { get; set; } = new();
|
||||
public List<CitationDto> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
@page "/catalog"
|
||||
@attribute [Authorize]
|
||||
@implements IDisposable
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject IReaderNavigationService ReaderNavigation
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject ILogger<Catalog> Logger
|
||||
|
||||
<div class="catalog-page">
|
||||
<header class="catalog-header">
|
||||
@@ -189,6 +193,11 @@
|
||||
private bool _isLoading = true;
|
||||
private List<LastReadBookDto>? _books;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged += HandleBooksChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
@@ -197,6 +206,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBooksChanged()
|
||||
{
|
||||
_ = InvokeAsync(LoadBooksAsync);
|
||||
}
|
||||
|
||||
private async Task LoadBooksAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
@@ -209,7 +223,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Catalog] Failed to load books: {ex.Message}");
|
||||
Logger.LogError(ex, "[Catalog] Failed to load books.");
|
||||
if (OperatingSystem.IsBrowser())
|
||||
{
|
||||
_isLoading = false;
|
||||
@@ -231,4 +245,9 @@
|
||||
// Showcase callback
|
||||
NavigationManager.NavigateTo("/profile");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contextual AI Recommendations -->
|
||||
<ContextualRecommendationsWidget />
|
||||
|
||||
<!-- Detailed Content Block Showcase -->
|
||||
<section class="architecture-guide-panel glass-panel">
|
||||
<div class="panel-header">
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
@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 Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject ILogger<Intelligence> Logger
|
||||
|
||||
<div class="intelligence-page">
|
||||
<header class="intelligence-header">
|
||||
<div class="header-title-section">
|
||||
<h1 class="neon-glow-text">Global Intelligence</h1>
|
||||
<p class="subtitle">Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="intelligence-layout glass-panel">
|
||||
<div class="intelligence-layout">
|
||||
<div class="chat-thread-container">
|
||||
@if (_chatMessages.Count == 0)
|
||||
{
|
||||
<div class="welcome-state">
|
||||
<div class="welcome-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="#8b8273" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" stroke-dasharray="2 2" stroke="rgba(139, 130, 115, 0.3)" />
|
||||
<path d="M12 6v12M8 8h8M6 12h12M8 16h8" stroke="rgba(139, 130, 115, 0.4)" />
|
||||
<path d="M9.5 4.5c-1.5 0-3 1.5-3 3.5s1.5 3 3 3h5c1.5 0 3-1 3-3s-1.5-3.5-3-3.5" />
|
||||
<path d="M9.5 19.5c-1.5 0-3-1.5-3-3.5s1.5-3 3-3h5c1.5 0 3 1 3 3s-1.5 3.5-3 3.5" />
|
||||
<circle cx="12" cy="12" r="2" fill="#8b8273" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Start Interrogating Your Library</h3>
|
||||
<p>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.</p>
|
||||
<div class="welcome-prompt">Zadaj pytanie globalne do całej biblioteki...</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -37,37 +38,7 @@
|
||||
<div class="chat-bubbles-scroll">
|
||||
@foreach (var message in _chatMessages)
|
||||
{
|
||||
<div class="message-row @(message.Sender == "User" ? "user-row" : "ai-row")" key="@message.Id">
|
||||
<div class="message-avatar">
|
||||
@if (message.Sender == "User")
|
||||
{
|
||||
<i class="bi bi-person-fill"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-robot"></i>
|
||||
}
|
||||
</div>
|
||||
<div class="message-bubble @(message.Sender == "User" ? "user-bubble" : "ai-bubble")">
|
||||
<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">
|
||||
@foreach (var segment in message.Segments)
|
||||
{
|
||||
@if (segment.IsCitation)
|
||||
{
|
||||
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@message.Citations" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderMarkdown(segment.Text)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AiResponseRenderer @key="message.Id" Message="@message" OwnedBooks="@_books" />
|
||||
}
|
||||
|
||||
@if (_isLoading)
|
||||
@@ -100,9 +71,9 @@
|
||||
<div class="input-panel-wrapper">
|
||||
<div class="scope-bar">
|
||||
<div class="scope-selector">
|
||||
<label for="book-select"><i class="bi bi-compass"></i> Scope:</label>
|
||||
<label for="book-select">Scope:</label>
|
||||
<select id="book-select" class="nexus-select" @bind="_selectedBookId">
|
||||
<option value="">All Books (Global Search)</option>
|
||||
<option value="">[ All Resources (Including Global Catalog) ]</option>
|
||||
@if (_books != null)
|
||||
{
|
||||
@foreach (var book in _books)
|
||||
@@ -121,7 +92,7 @@
|
||||
@bind:event="oninput"
|
||||
@onkeyup="HandleKeyUp"
|
||||
disabled="@_isLoading" />
|
||||
<button class="btn-nexus btn-nexus-primary search-btn"
|
||||
<button class="search-btn"
|
||||
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
|
||||
@onclick="AskQuestionAsync">
|
||||
@if (_isLoading)
|
||||
@@ -146,20 +117,52 @@
|
||||
private List<LastReadBookDto>? _books;
|
||||
private List<ChatMessage> _chatMessages = new();
|
||||
|
||||
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged += HandleBooksChanged;
|
||||
await LoadBooksAsync();
|
||||
}
|
||||
|
||||
private async Task LoadBooksAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_books = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
|
||||
LibraryStateService.OwnedBooks = _books;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Intelligence] Failed to load books for scope selector: {ex.Message}");
|
||||
Logger.LogError(ex, "[Intelligence] Failed to load books.");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBooksChanged()
|
||||
{
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
_books = LibraryStateService.OwnedBooks;
|
||||
|
||||
// Check if any existing message in the chat thread was paywalled,
|
||||
// and update its owned state dynamically.
|
||||
if (_books != null)
|
||||
{
|
||||
foreach (var message in _chatMessages)
|
||||
{
|
||||
if (message.IsPaywalled && !string.IsNullOrEmpty(message.SourceBookTitle))
|
||||
{
|
||||
var isNowOwned = _books.Any(b => b.Title.Equals(message.SourceBookTitle, StringComparison.OrdinalIgnoreCase));
|
||||
if (isNowOwned)
|
||||
{
|
||||
message.IsPaywalled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleKeyUp(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_question) && !_isLoading)
|
||||
@@ -176,7 +179,7 @@
|
||||
_question = string.Empty; // Clear input field immediately
|
||||
_isLoading = true;
|
||||
|
||||
// Add user query message
|
||||
// Add user query message with custom background in renderer
|
||||
_chatMessages.Add(new ChatMessage
|
||||
{
|
||||
Sender = "User",
|
||||
@@ -196,28 +199,80 @@
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
|
||||
|
||||
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
|
||||
if (result.IsSuccess)
|
||||
if (ebookId == null)
|
||||
{
|
||||
var response = result.Value;
|
||||
_chatMessages.Add(new ChatMessage
|
||||
var result = await KnowledgeService.GetGlobalIntelligenceAsync(userQuestion, userId, tenantId);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = response.Answer,
|
||||
Segments = ParseSegments(response.Answer),
|
||||
Citations = response.Citations
|
||||
});
|
||||
var response = result.Value;
|
||||
var chatMsg = CreateGlobalAiChatMessage(response, _books);
|
||||
_chatMessages.Add(chatMsg);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
||||
_chatMessages.Add(new ChatMessage
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = errMsg,
|
||||
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
||||
_chatMessages.Add(new ChatMessage
|
||||
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = errMsg,
|
||||
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||
});
|
||||
var response = result.Value;
|
||||
|
||||
// --- Paywall Simulation Logic ---
|
||||
// If the user does not own "Architektura .NET 10 i Ekosystem Blazor"
|
||||
// and the question refers to Blazor/architecture/C#/etc,
|
||||
// we simulate that the RAG search pulled a citation from that unowned book.
|
||||
var hasBlazorBook = _books != null && _books.Any(b => b.Title.Contains("Architektura .NET 10", StringComparison.OrdinalIgnoreCase));
|
||||
var isSimulatingPaywall = !hasBlazorBook &&
|
||||
(userQuestion.Contains("blazor", StringComparison.OrdinalIgnoreCase) ||
|
||||
userQuestion.Contains("net", StringComparison.OrdinalIgnoreCase) ||
|
||||
userQuestion.Contains("architektura", StringComparison.OrdinalIgnoreCase) ||
|
||||
userQuestion.Contains("c#", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (isSimulatingPaywall)
|
||||
{
|
||||
var mockCitationId = Guid.NewGuid().ToString();
|
||||
var mockCitation = new CitationDto
|
||||
{
|
||||
CitationId = mockCitationId,
|
||||
SourceBook = "Architektura .NET 10 i Ekosystem Blazor",
|
||||
Author = "Nexus Architect",
|
||||
Snippet = "Konfiguracja kontenera dependency injection w standardzie .NET 10 Blazor przy użyciu Native AOT wymaga wyeliminowania dynamicznej refleksji.",
|
||||
PageNumber = 42
|
||||
};
|
||||
|
||||
if (response.Citations == null)
|
||||
{
|
||||
response.Citations = new List<CitationDto>();
|
||||
}
|
||||
response.Citations.Add(mockCitation);
|
||||
|
||||
response.Answer = "Aby poprawnie skonfigurować architekturę .NET 10 Blazor pod Native AOT, należy unikać dynamicznego ładowania typów. Konieczne jest używanie generatorów kodu źródłowego (Source Generators) do rejestracji zależności w kontenerze DI. W ten sposób kompilator AOT może przeanalizować graf zależności podczas kompilacji. [Source ID: " + mockCitationId + "]\n\nPełny przykładowy kod konfiguracji Program.cs wygląda następująco:\n```csharp\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddSingleton<IService, AotService>();\n```";
|
||||
}
|
||||
|
||||
var chatMsg = CreateAiChatMessage(response, _books);
|
||||
_chatMessages.Add(chatMsg);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
||||
_chatMessages.Add(new ChatMessage
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = errMsg,
|
||||
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -237,12 +292,115 @@
|
||||
}
|
||||
}
|
||||
|
||||
private ChatMessage CreateAiChatMessage(GroundedResponseDto response, List<LastReadBookDto>? ownedBooks)
|
||||
{
|
||||
var msg = new ChatMessage
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = response.Answer,
|
||||
Segments = ParseSegments(response.Answer),
|
||||
Citations = response.Citations
|
||||
};
|
||||
|
||||
// Check if paywalled: citations contain a book not in ownedBooks
|
||||
var unownedCitation = response.Citations.FirstOrDefault(c =>
|
||||
!string.IsNullOrEmpty(c.SourceBook) &&
|
||||
(ownedBooks == null || !ownedBooks.Any(ob => ob.Title.Equals(c.SourceBook, StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
if (unownedCitation != null)
|
||||
{
|
||||
msg.IsPaywalled = true;
|
||||
msg.SourceBookTitle = unownedCitation.SourceBook;
|
||||
|
||||
// Split sentences *once* during creation for Native AOT rendering performance
|
||||
var (clear, _) = SplitSentences(response.Answer);
|
||||
msg.ClearText = clear;
|
||||
msg.BlurredTeaserText = "\n\n// [Blokada Paywall] Pełna treść oraz kody źródłowe C# zostały zablokowane.\npublic class ArchitekturaProcessor {\n public async Task ProcessAsync() {\n // Zaimplementuj wzorzec CQRS...\n throw new PaywallException(\"Kup publikację w katalogu\");\n }\n}";
|
||||
}
|
||||
else
|
||||
{
|
||||
msg.IsPaywalled = false;
|
||||
msg.ClearText = response.Answer;
|
||||
msg.BlurredTeaserText = string.Empty;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
private ChatMessage CreateGlobalAiChatMessage(NexusReader.Application.Queries.Intelligence.IntelligenceResponse response, List<LastReadBookDto>? ownedBooks)
|
||||
{
|
||||
var msg = new ChatMessage
|
||||
{
|
||||
Sender = "AI",
|
||||
Text = response.ResponseText,
|
||||
Segments = ParseSegments(response.ResponseText),
|
||||
Citations = response.Citations ?? new List<CitationDto>()
|
||||
};
|
||||
|
||||
if (response.HasPaywall)
|
||||
{
|
||||
msg.IsPaywalled = true;
|
||||
msg.SourceBookTitle = response.LockedBookTitle ?? string.Empty;
|
||||
|
||||
// Split sentences *once* during creation for Native AOT rendering performance
|
||||
var (clear, _) = SplitSentences(response.ResponseText);
|
||||
msg.ClearText = clear;
|
||||
msg.BlurredTeaserText = "\n\n// [Blokada Paywall] Pełna treść oraz kody źródłowe C# zostały zablokowane.\npublic class ArchitekturaProcessor {\n public async Task ProcessAsync() {\n // Zaimplementuj wzorzec CQRS...\n throw new PaywallException(\"Kup publikację w katalogu\");\n }\n}";
|
||||
}
|
||||
else
|
||||
{
|
||||
msg.IsPaywalled = false;
|
||||
msg.ClearText = response.ResponseText;
|
||||
msg.BlurredTeaserText = string.Empty;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
private (string ClearText, string BlurredText) SplitSentences(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return (string.Empty, string.Empty);
|
||||
|
||||
int sentenceCount = 0;
|
||||
int firstSplit = -1;
|
||||
int secondSplit = -1;
|
||||
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
char c = text[i];
|
||||
if (c == '.' || c == '?' || c == '!')
|
||||
{
|
||||
if (i + 1 == text.Length || char.IsWhiteSpace(text[i + 1]))
|
||||
{
|
||||
sentenceCount++;
|
||||
if (sentenceCount == 1)
|
||||
{
|
||||
firstSplit = i + 1;
|
||||
}
|
||||
else if (sentenceCount == 2)
|
||||
{
|
||||
secondSplit = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int splitIndex = secondSplit != -1 ? secondSplit : firstSplit;
|
||||
|
||||
if (splitIndex != -1 && splitIndex < text.Length)
|
||||
{
|
||||
return (text.Substring(0, splitIndex), text.Substring(splitIndex));
|
||||
}
|
||||
|
||||
return (text, string.Empty);
|
||||
}
|
||||
|
||||
private List<ResponseSegment> ParseSegments(string text)
|
||||
{
|
||||
var segments = new List<ResponseSegment>();
|
||||
if (string.IsNullOrEmpty(text)) return segments;
|
||||
|
||||
// Matches [Source ID: some-id] OR raw GUIDs in brackets [e225e58f-7539-cd51-e0ab-82741ec7e65c]
|
||||
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);
|
||||
@@ -285,28 +443,8 @@
|
||||
return segments;
|
||||
}
|
||||
|
||||
private MarkupString RenderMarkdown(string text)
|
||||
public void Dispose()
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
|
||||
|
||||
// 1. HTML Encode to prevent XSS
|
||||
var html = System.Net.WebUtility.HtmlEncode(text);
|
||||
|
||||
// 2. Bold: **text** -> <strong>text</strong>
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
|
||||
|
||||
// 3. Italic: *text* -> <em>text</em>
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
|
||||
|
||||
// 4. Code blocks: ```language ... ``` -> <pre class="nexus-code-block"><code>...</code></pre>
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
|
||||
|
||||
// 5. Inline Code: `code` -> <code class="nexus-inline-code">code</code>
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
|
||||
|
||||
// 6. Newlines: \n -> <br />
|
||||
html = html.Replace("\n", "<br />");
|
||||
|
||||
return new MarkupString(html);
|
||||
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,18 @@
|
||||
.intelligence-page {
|
||||
padding: 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
height: calc(100vh - 100px);
|
||||
margin: -2.5rem;
|
||||
height: 100vh;
|
||||
background: #121214;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.intelligence-header {
|
||||
margin-bottom: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.neon-glow-text {
|
||||
font-family: var(--nexus-font-sans);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 0.25rem 0;
|
||||
background: linear-gradient(135deg, var(--nexus-neon) 0%, rgba(0, 255, 153, 0.7) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.2));
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
@media (max-width: 768px) {
|
||||
.intelligence-page {
|
||||
margin: -1.25rem;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
}
|
||||
|
||||
.intelligence-layout {
|
||||
@@ -35,202 +20,90 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-thread-container {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
padding: 3rem 4rem 2rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-thread-container {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbars */
|
||||
.chat-thread-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.chat-thread-container::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
background: transparent;
|
||||
}
|
||||
.chat-thread-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 153, 0.2);
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.chat-thread-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 153, 0.4);
|
||||
background: rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.chat-bubbles-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 85%;
|
||||
animation: bubble-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.ai-row {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
/* State 1: Initial Empty Screen */
|
||||
.welcome-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
animation: fade-in-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.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);
|
||||
.welcome-icon {
|
||||
margin-bottom: 2rem;
|
||||
filter: drop-shadow(0 0 15px rgba(139, 130, 115, 0.15));
|
||||
}
|
||||
|
||||
.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: var(--radius-lg);
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
font-size: 0.975rem;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
border-top-right-radius: 4px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.ai-bubble {
|
||||
background: rgba(10, 20, 30, 0.55);
|
||||
border: 1px solid rgba(0, 255, 153, 0.2);
|
||||
color: #e2e8f0;
|
||||
border-top-left-radius: 4px;
|
||||
box-shadow: 0 4px 15px rgba(0, 255, 153, 0.05);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
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 & Markdown */
|
||||
.message-content p {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.message-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nexus-code-block {
|
||||
background: rgba(0, 0, 0, 0.4) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.nexus-inline-code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #f472b6; /* Light pink for inline code */
|
||||
}
|
||||
|
||||
/* Pending State Bubble */
|
||||
.pending-bubble {
|
||||
border-color: rgba(0, 255, 153, 0.4);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.1);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--nexus-neon);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: typing-bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
.loading-label {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
.welcome-prompt {
|
||||
font-family: var(--nexus-font-sans, inherit);
|
||||
color: #e4e4e7;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* Input Controls */
|
||||
.chat-input-controls {
|
||||
padding: 1.5rem 2rem 2rem 2rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 1.5rem 4rem 3rem 4rem;
|
||||
background: linear-gradient(to top, #121214 70%, rgba(18, 18, 20, 0));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-input-controls {
|
||||
padding: 1rem 1rem 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input-panel-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scope-bar {
|
||||
@@ -241,46 +114,48 @@
|
||||
.scope-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 500;
|
||||
color: #8b8273;
|
||||
}
|
||||
|
||||
.nexus-select {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
padding: 0.35rem 2rem 0.35rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: #e4e4e7;
|
||||
padding: 0.4rem 2rem 0.4rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.825rem;
|
||||
transition: all 0.25s ease;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b8273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 0.85em;
|
||||
}
|
||||
|
||||
.nexus-select:focus {
|
||||
border-color: var(--nexus-neon);
|
||||
box-shadow: 0 0 8px rgba(0, 255, 153, 0.2);
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.input-field-group {
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem;
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 0.4rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.input-field-group:focus-within {
|
||||
border-color: var(--nexus-neon);
|
||||
background: rgba(0, 255, 153, 0.01);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
background: #1a1a1e;
|
||||
box-shadow: 0 10px 35px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.nexus-input {
|
||||
@@ -288,68 +163,92 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-size: 0.975rem;
|
||||
outline: none;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nexus-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
color: #8b8273;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
padding: 0 !important;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #10b981;
|
||||
border: none;
|
||||
color: #121214;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.welcome-state {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 4rem 2rem;
|
||||
.search-btn:hover:not(:disabled) {
|
||||
background: #0d9668;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.search-btn:disabled {
|
||||
background: rgba(26, 26, 30, 0.8);
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.02);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Typing / Loading Indicators */
|
||||
.message-bubble.pending-bubble {
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
background: rgba(16, 185, 129, 0.03);
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
color: rgba(0, 255, 153, 0.4);
|
||||
margin-bottom: 1.5rem;
|
||||
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.2));
|
||||
animation: pulse 2.5s infinite alternate;
|
||||
.typing-indicator span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: typing-bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.welcome-state h3 {
|
||||
color: #ffffff;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
.welcome-state p {
|
||||
max-width: 550px;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
.loading-label {
|
||||
font-size: 0.825rem;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(18, 18, 20, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: #000000;
|
||||
border-top-color: #121214;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Keyframe Animations */
|
||||
@keyframes bubble-fade-in {
|
||||
0% { opacity: 0; transform: translateY(10px) scale(0.98); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
0% { opacity: 0; transform: translateY(15px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
@@ -357,11 +256,6 @@
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.96); opacity: 0.8; }
|
||||
100% { transform: scale(1.04); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
@page "/my-books"
|
||||
@attribute [Authorize]
|
||||
@implements IDisposable
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject IReaderNavigationService ReaderNavigation
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject ILogger<MyBooks> Logger
|
||||
|
||||
<div class="my-books-page">
|
||||
<header class="my-books-header">
|
||||
@@ -108,6 +112,11 @@
|
||||
private bool _isLoading = true;
|
||||
private List<LastReadBookDto>? _books;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged += HandleBooksChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
@@ -116,6 +125,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBooksChanged()
|
||||
{
|
||||
_ = InvokeAsync(LoadBooksAsync);
|
||||
}
|
||||
|
||||
private async Task LoadBooksAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
@@ -128,7 +142,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MyBooks] Failed to load books: {ex.Message}");
|
||||
Logger.LogError(ex, "[MyBooks] Failed to load books.");
|
||||
if (OperatingSystem.IsBrowser())
|
||||
{
|
||||
_isLoading = false;
|
||||
@@ -149,4 +163,9 @@
|
||||
{
|
||||
ReaderNavigation.NavigateToBook(bookId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface ILibraryStateService
|
||||
{
|
||||
List<LastReadBookDto>? OwnedBooks { get; set; }
|
||||
event Action? OnBooksChanged;
|
||||
void NotifyBooksChanged();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NexusReader.Application.Queries.Recommendations;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides contextual book recommendations based on the user's active reading state.
|
||||
/// Abstracts the HTTP transport layer from Blazor UI components.
|
||||
/// </summary>
|
||||
public interface IRecommendationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches contextual recommendations for the authenticated user.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>
|
||||
/// A list of <see cref="RecommendationDto"/> on success, or an empty list when none are available.
|
||||
/// Returns <c>null</c> if the request fails due to a transport or server error.
|
||||
/// </returns>
|
||||
Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public class LibraryStateService : ILibraryStateService
|
||||
{
|
||||
private List<LastReadBookDto>? _ownedBooks;
|
||||
|
||||
public List<LastReadBookDto>? OwnedBooks
|
||||
{
|
||||
get => _ownedBooks;
|
||||
set
|
||||
{
|
||||
_ownedBooks = value;
|
||||
NotifyBooksChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public event Action? OnBooksChanged;
|
||||
|
||||
public void NotifyBooksChanged()
|
||||
{
|
||||
OnBooksChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AOT-safe string parsing utility to isolate paywall teaser details without regex overhead.
|
||||
/// </summary>
|
||||
public static class PaywallParser
|
||||
{
|
||||
public static bool TryParsePaywallTrigger(
|
||||
string rawText,
|
||||
out string displayTeaserText,
|
||||
out Guid lockedBookId,
|
||||
out string lockedBookTitle,
|
||||
out int localScore,
|
||||
out int globalScore)
|
||||
{
|
||||
displayTeaserText = rawText;
|
||||
lockedBookId = Guid.Empty;
|
||||
lockedBookTitle = string.Empty;
|
||||
localScore = 0;
|
||||
globalScore = 0;
|
||||
|
||||
if (string.IsNullOrEmpty(rawText))
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> span = rawText.AsSpan();
|
||||
int tokenStartIndex = span.IndexOf("[PAYWALL_TRIGGER:");
|
||||
if (tokenStartIndex == -1)
|
||||
return false;
|
||||
|
||||
displayTeaserText = span.Slice(0, tokenStartIndex).Trim().ToString();
|
||||
|
||||
ReadOnlySpan<char> tokenContent = span.Slice(tokenStartIndex + "[PAYWALL_TRIGGER:".Length);
|
||||
int tokenEndIndex = tokenContent.IndexOf(']');
|
||||
if (tokenEndIndex == -1)
|
||||
return false;
|
||||
|
||||
tokenContent = tokenContent.Slice(0, tokenEndIndex);
|
||||
|
||||
int firstColonIdx = tokenContent.IndexOf(':');
|
||||
if (firstColonIdx == -1)
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> guidSpan = tokenContent.Slice(0, firstColonIdx);
|
||||
if (!Guid.TryParse(guidSpan, out lockedBookId))
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> remaining = tokenContent.Slice(firstColonIdx + 1);
|
||||
|
||||
int lastColonIdx = remaining.LastIndexOf(':');
|
||||
if (lastColonIdx == -1)
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> globalScoreSpan = remaining.Slice(lastColonIdx + 1);
|
||||
if (!int.TryParse(globalScoreSpan, out globalScore))
|
||||
return false;
|
||||
|
||||
remaining = remaining.Slice(0, lastColonIdx);
|
||||
|
||||
int secondLastColonIdx = remaining.LastIndexOf(':');
|
||||
if (secondLastColonIdx == -1)
|
||||
return false;
|
||||
|
||||
ReadOnlySpan<char> localScoreSpan = remaining.Slice(secondLastColonIdx + 1);
|
||||
if (!int.TryParse(localScoreSpan, out localScore))
|
||||
return false;
|
||||
|
||||
lockedBookTitle = remaining.Slice(0, secondLastColonIdx).ToString();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,4 @@
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
@using NexusReader.Application.Queries.Recommendations
|
||||
|
||||
Reference in New Issue
Block a user