Files
Nexus.Reader/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor
T
Antigravity 1d6862016d 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>
2026-06-06 13:38:48 +00:00

201 lines
8.2 KiB
Plaintext

@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)
{
<div class="selection-ai-panel @(_positionBelow ? "below" : "")" style="@_style">
<button id="summary-btn" class="toolbar-btn primary @(IsLoadingSummary ? "loading" : "") @(IsAnyLoading ? "disabled cursor-not-allowed opacity-50" : "")"
disabled="@IsAnyLoading"
@onclick="RequestSummaryAsync">
@if (IsLoadingSummary)
{
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style="animation: spin 1s linear infinite; width: 14px; height: 14px; color: currentColor; display: inline-block; margin-right: 4px;">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" style="opacity: 0.25;"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" style="opacity: 0.75;"></path>
</svg>
<span class="btn-text">Podsumowywanie...</span>
}
else
{
<NexusIcon Name="book-open" Size="14" Class="btn-icon" />
<span class="btn-text">Podsumuj</span>
}
</button>
<div class="toolbar-divider"></div>
<button id="quiz-btn" class="toolbar-btn secondary @(IsLoadingQuiz ? "loading" : "") @(IsAnyLoading ? "disabled cursor-not-allowed opacity-50" : "")"
disabled="@IsAnyLoading"
@onclick="GenerateQuizAsync">
@if (IsLoadingQuiz)
{
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style="animation: spin 1s linear infinite; width: 14px; height: 14px; color: currentColor; display: inline-block; margin-right: 4px;">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" style="opacity: 0.25;"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" style="opacity: 0.75;"></path>
</svg>
<span class="btn-text">Generowanie...</span>
}
else
{
<NexusIcon Name="target" Size="14" Class="btn-icon" />
<span class="btn-text">Quiz</span>
}
</button>
</div>
}
@code {
[Parameter] public string SelectedText { get; set; } = string.Empty;
[Parameter] public string BlockId { get; set; } = string.Empty;
[Parameter] public SelectionCoordinates? Coordinates { get; set; }
[Parameter] public string FullPageContent { get; set; } = string.Empty;
private bool IsVisible => !string.IsNullOrEmpty(SelectedText) && Coordinates != null;
private bool IsLoadingSummary = false;
private bool IsLoadingQuiz = false;
private bool IsAnyLoading => IsLoadingSummary || IsLoadingQuiz;
private string _style = "visibility: hidden; opacity: 0; pointer-events: none;";
private bool _positionBelow = false;
private SelectionCoordinates? _lastCoordinates;
protected override void OnParametersSet()
{
Logger.LogDebug("[SelectionAiPanel] Parameters set. SelectedText: {Length} chars, Coordinates: {Top}", SelectedText.Length, Coordinates?.Top);
if (Coordinates != _lastCoordinates)
{
_lastCoordinates = Coordinates;
_style = "visibility: hidden; opacity: 0; pointer-events: none;";
_positionBelow = false;
}
// Reset loading states when parameters change
IsLoadingSummary = false;
IsLoadingQuiz = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (IsVisible && _style.Contains("visibility: hidden"))
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
var result = await module.InvokeAsync<PositionResult>("positionToolbar");
if (result != null)
{
_style = string.Create(System.Globalization.CultureInfo.InvariantCulture,
$"left: {result.Left:F1}px !important; " +
$"top: {result.Top:F1}px !important; " +
$"visibility: visible !important; " +
$"opacity: 1 !important; " +
$"pointer-events: auto !important;");
_positionBelow = result.Below;
StateHasChanged();
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "[SelectionAiPanel] Error positioning toolbar.");
}
}
}
private async Task RequestSummaryAsync()
{
if (IsAnyLoading) return;
IsLoadingSummary = true;
StateHasChanged();
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
var selectedText = await module.InvokeAsync<string>("getSelectionText");
if (string.IsNullOrWhiteSpace(selectedText))
{
selectedText = SelectedText;
}
if (!string.IsNullOrWhiteSpace(selectedText))
{
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: "";
_ = Coordinator.StartSelectionSummaryAsync($"{contextPrompt}{selectedText}");
await CloseAsync();
await InteractionService.RequestAssistant();
}
}
catch (Exception ex)
{
Logger.LogError(ex, "[SelectionAiPanel] Error requesting summary for block {BlockId}.", BlockId);
}
finally
{
IsLoadingSummary = false;
StateHasChanged();
}
}
private async Task GenerateQuizAsync()
{
if (IsAnyLoading) return;
IsLoadingQuiz = true;
StateHasChanged();
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
var selectedText = await module.InvokeAsync<string>("getSelectionText");
if (string.IsNullOrWhiteSpace(selectedText))
{
selectedText = SelectedText;
}
if (!string.IsNullOrWhiteSpace(selectedText))
{
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: "";
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{selectedText}");
if (result.IsSuccess)
{
await CloseAsync();
await QuizService.RequestQuiz(BlockId);
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "[SelectionAiPanel] Error generating quiz for block {BlockId}.", BlockId);
}
finally
{
IsLoadingQuiz = false;
StateHasChanged();
}
}
private async Task CloseAsync()
{
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
}
private class PositionResult
{
public double Left { get; set; }
public double Top { get; set; }
public bool Below { get; set; }
}
}