style: complete Light Sepia theme overrides for user dashboard #78

Merged
mjasin merged 11 commits from feature/theme-sync-engine into develop 2026-06-07 16:56:37 +00:00
17 changed files with 549 additions and 21 deletions
Showing only changes of commit ce4687ee93 - Show all commits
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -94,12 +95,21 @@ internal sealed class VectorSearchStore : IVectorSearchStore
private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("[VectorSearchStore] Attempted to generate embedding from empty text. Returning zero vector.");
return Array.Empty<float>();
}
var sw = Stopwatch.StartNew();
var response = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(
new[] { text },
new EmbeddingGenerationOptions { Dimensions = 768 },
cancellationToken: ct), cancellationToken);
sw.Stop();
_logger.LogDebug("[VectorSearchStore] Embedding generated in {ElapsedMs}ms for text of {Length} chars.", sw.ElapsedMilliseconds, text.Length);
return response.First().Vector.ToArray();
}
@@ -132,10 +142,17 @@ internal sealed class VectorSearchStore : IVectorSearchStore
private async Task<List<VectorChunk>> ExecuteSearchAsync(float[] queryVector, Qdrant.Client.Grpc.Filter filter, int limit, CancellationToken cancellationToken)
{
if (queryVector.Length == 0)
{
_logger.LogWarning("[VectorSearchStore] Empty query vector — skipping Qdrant search.");
return new List<VectorChunk>();
}
try
{
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var sw = Stopwatch.StartNew();
var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units",
vector: queryVector,
@@ -143,6 +160,8 @@ internal sealed class VectorSearchStore : IVectorSearchStore
limit: (ulong)limit,
cancellationToken: cancellationToken
);
sw.Stop();
_logger.LogInformation("[VectorSearchStore] Qdrant search returned {Count} results in {ElapsedMs}ms.", response.Count, sw.ElapsedMilliseconds);
return response.Select(point =>
{
@@ -169,6 +188,7 @@ internal sealed class VectorSearchStore : IVectorSearchStore
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
if (!exists)
{
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' does not exist — creating.", collectionName);
await _qdrantClient.CreateCollectionAsync(
collectionName: collectionName,
vectorsConfig: new Qdrant.Client.Grpc.VectorParams
@@ -178,11 +198,13 @@ internal sealed class VectorSearchStore : IVectorSearchStore
},
cancellationToken: cancellationToken
);
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName);
}
}
catch (Exception)
catch (Exception ex)
{
// Ignore concurrent creation conflicts in multi-threaded/concurrent flows
// Log concurrent creation conflicts (e.g., AlreadyExists gRPC status) but do not propagate.
_logger.LogWarning(ex, "[VectorSearchStore] Non-fatal error while ensuring collection '{CollectionName}' exists. Possible concurrent creation.", collectionName);
}
}
}
@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Queries.Recommendations;
@@ -13,24 +14,31 @@ namespace NexusReader.Infrastructure.Queries;
/// <summary>
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
/// performing semantic search using IVectorSearchStore with book exclusion, and mapping upsells.
/// performing semantic search using <see cref="IVectorSearchStore"/> with book exclusion, and mapping upsells.
/// </summary>
public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>>
{
private readonly IUserReadingStateStore _readingStateStore;
private readonly IUserLibraryStore _libraryStore;
private readonly IVectorSearchStore _vectorSearchStore;
private readonly ILogger<GetContextualRecommendationsQueryHandler> _logger;
/// <summary>
/// Initializes a new instance of <see cref="GetContextualRecommendationsQueryHandler"/>.
/// </summary>
public GetContextualRecommendationsQueryHandler(
IUserReadingStateStore readingStateStore,
IUserLibraryStore libraryStore,
IVectorSearchStore vectorSearchStore)
IVectorSearchStore vectorSearchStore,
ILogger<GetContextualRecommendationsQueryHandler> logger)
{
_readingStateStore = readingStateStore;
_libraryStore = libraryStore;
_vectorSearchStore = vectorSearchStore;
_logger = logger;
}
/// <inheritdoc />
public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.UserId))
@@ -44,7 +52,7 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken);
if (ebookId == null)
{
// Fallback: brand-new user with no reading history, return empty recommendations list safely
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId);
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
}
@@ -55,14 +63,17 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken);
}
// Fallback: if no active chapter or content, try retrieving any chapter content from this book
if (string.IsNullOrEmpty(chapterContent))
// Guard: empty chapter content cannot produce a meaningful embedding
if (string.IsNullOrWhiteSpace(chapterContent))
{
_logger.LogWarning("[Recommendations] Chapter content is empty for chapterId={ChapterId}. Returning empty list.", chapterId);
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
}
// Step 3: Perform similarity search using IVectorSearchStore
var resolvedTenantId = tenantId ?? "global";
_logger.LogDebug("[Recommendations] Performing vector search for user {UserId}, book {EbookId}, tenant {TenantId}.", request.UserId, ebookId, resolvedTenantId);
var searchResults = await _vectorSearchStore.SearchGlobalExcludeAsync(
chapterContent,
resolvedTenantId,
@@ -103,7 +114,10 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
chapterTitle = labelProp.GetString() ?? chapterTitle;
}
}
catch { }
catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
}
}
}
@@ -119,10 +133,12 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
));
}
_logger.LogInformation("[Recommendations] Returning {Count} recommendations for user {UserId}.", recommendations.Count, request.UserId);
return Result.Ok(new ContextualRecommendationResponse(recommendations));
}
catch (Exception ex)
{
_logger.LogError(ex, "[Recommendations] Downstream vector database or state query failed for user {UserId}.", request.UserId);
return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex));
}
}
@@ -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
{
@@ -2,10 +2,12 @@
@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">
@@ -200,12 +202,12 @@
}
else
{
Console.WriteLine("[AiResponseRenderer] Purchase failed on server.");
Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId);
}
}
catch (Exception ex)
{
Console.WriteLine($"[AiResponseRenderer] Error processing purchase: {ex.Message}");
Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId);
}
finally
{
@@ -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}");
}
}
}
@@ -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;
}
}
@@ -4,11 +4,13 @@
@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">
@@ -221,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;
@@ -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">
@@ -7,11 +7,13 @@
@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">
<div class="intelligence-layout">
@@ -130,7 +132,7 @@
}
catch (Exception ex)
{
Console.WriteLine($"[Intelligence] Failed to load books: {ex.Message}");
Logger.LogError(ex, "[Intelligence] Failed to load books.");
}
}
@@ -4,10 +4,12 @@
@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">
@@ -140,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;
@@ -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);
}
+1
View File
@@ -20,3 +20,4 @@
@using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User
@using NexusReader.Application.Queries.Reader
@using NexusReader.Application.Queries.Recommendations
+1
View File
@@ -42,6 +42,7 @@ builder.Services.AddCascadingAuthenticationState();
// AI & Content Services
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
builder.Services.AddScoped<IConceptsMapService, WasmConceptsMapService>();
builder.Services.AddScoped<IRecommendationService, RecommendationService>();
builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
builder.Services.AddHttpClient("NexusAPI", client =>
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Common;
using NexusReader.Application.Queries.Recommendations;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Client.Services;
/// <summary>
/// WASM implementation of <see cref="IRecommendationService"/> that fetches contextual recommendations
/// from the <c>/api/recommendations</c> server endpoint via a named <c>NexusAPI</c> HTTP client.
/// </summary>
internal sealed class RecommendationService : IRecommendationService
{
private readonly HttpClient _http;
private readonly ILogger<RecommendationService> _logger;
/// <summary>
/// Initializes a new instance of <see cref="RecommendationService"/>.
/// </summary>
public RecommendationService(HttpClient http, ILogger<RecommendationService> logger)
{
_http = http;
_logger = logger;
}
/// <inheritdoc />
public async Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync("/api/recommendations", cancellationToken);
if (response.StatusCode == HttpStatusCode.NoContent || response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogInformation("[RecommendationService] No recommendations available (status {StatusCode}).", response.StatusCode);
return new List<RecommendationDto>();
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync(
AppJsonContext.Default.ContextualRecommendationResponse,
cancellationToken);
if (result is null)
{
_logger.LogWarning("[RecommendationService] Deserialised response was null.");
return new List<RecommendationDto>();
}
_logger.LogInformation("[RecommendationService] Received {Count} recommendations.", result.Recommendations.Count);
return result.Recommendations;
}
catch (HttpRequestException httpEx)
{
_logger.LogError(httpEx, "[RecommendationService] HTTP error fetching recommendations: {Message}", httpEx.Message);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "[RecommendationService] Unexpected error fetching recommendations.");
return null;
}
}
}
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Queries.Recommendations;
@@ -17,6 +18,7 @@ public class GetContextualRecommendationsQueryTests
private readonly Mock<IUserReadingStateStore> _readingStateStoreMock;
private readonly Mock<IUserLibraryStore> _libraryStoreMock;
private readonly Mock<IVectorSearchStore> _vectorSearchStoreMock;
private readonly Mock<ILogger<GetContextualRecommendationsQueryHandler>> _loggerMock;
private readonly GetContextualRecommendationsQueryHandler _handler;
public GetContextualRecommendationsQueryTests()
@@ -24,11 +26,13 @@ public class GetContextualRecommendationsQueryTests
_readingStateStoreMock = new Mock<IUserReadingStateStore>();
_libraryStoreMock = new Mock<IUserLibraryStore>();
_vectorSearchStoreMock = new Mock<IVectorSearchStore>();
_loggerMock = new Mock<ILogger<GetContextualRecommendationsQueryHandler>>();
_handler = new GetContextualRecommendationsQueryHandler(
_readingStateStoreMock.Object,
_libraryStoreMock.Object,
_vectorSearchStoreMock.Object
_vectorSearchStoreMock.Object,
_loggerMock.Object
);
}