From 0a3ca77d46e5b89a7f090e6281dee250cee32ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Thu, 21 May 2026 20:16:14 +0200 Subject: [PATCH] feat(ui): implement premium NexusSearchBox component and integrate semantic search navigation --- .../Library/SearchLibrarySemanticallyQuery.cs | 46 +-- .../Persistence/AppDbContext.cs | 11 +- .../Persistence/AppDbContextFactory.cs | 3 +- .../Components/Atoms/NexusSearchBox.razor | 327 +++++++++++++++++- .../Components/Atoms/NexusSearchBox.razor.css | 298 ++++++++++++++-- .../Components/Organisms/ReaderCanvas.razor | 11 + .../Services/IReaderNavigationService.cs | 1 + .../Services/ReaderNavigationService.cs | 1 + .../Queries/QueryTests.cs | 50 ++- 9 files changed, 632 insertions(+), 116 deletions(-) diff --git a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs index 19327c6..a613286 100644 --- a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs +++ b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs @@ -1,18 +1,7 @@ using FluentResults; using MediatR; -using Pgvector; -using Pgvector.EntityFrameworkCore; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.DTOs.AI; -using Microsoft.Extensions.AI; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Resilience; -using Polly; -using Polly.Registry; -using Mapster; -using MapsterMapper; - -using NexusReader.Data.Persistence; namespace NexusReader.Application.Queries.Library; @@ -21,21 +10,11 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId, public class SearchLibrarySemanticallyQueryHandler : IRequestHandler>> { - private readonly IEmbeddingGenerator> _embeddingGenerator; - private readonly IDbContextFactory _dbContextFactory; - private readonly ResiliencePipeline _retryPipeline; - private readonly IMapper _mapper; + private readonly IKnowledgeService _knowledgeService; - public SearchLibrarySemanticallyQueryHandler( - IEmbeddingGenerator> embeddingGenerator, - IDbContextFactory dbContextFactory, - ResiliencePipelineProvider pipelineProvider, - IMapper mapper) + public SearchLibrarySemanticallyQueryHandler(IKnowledgeService knowledgeService) { - _embeddingGenerator = embeddingGenerator; - _dbContextFactory = dbContextFactory; - _retryPipeline = pipelineProvider.GetPipeline("ai-retry"); - _mapper = mapper; + _knowledgeService = knowledgeService; } public async Task>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken) @@ -45,19 +24,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler - await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: ct), cancellationToken); - var queryVector = new Vector(embeddingResponse.First().Vector.ToArray()); - - await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var cacheEntries = await dbContext.SemanticKnowledgeCache - .Where(c => c.TenantId == request.TenantId && c.Embedding != null) - .OrderBy(c => c.Embedding!.CosineDistance(queryVector)) - .Take(request.Limit) - .ToListAsync(cancellationToken); - - var dtos = _mapper.Map>(cacheEntries); - return Result.Ok(dtos); + return await _knowledgeService.SearchLibrarySemanticallyAsync( + request.QueryText, + request.TenantId, + request.Limit, + cancellationToken); } } diff --git a/src/NexusReader.Data/Persistence/AppDbContext.cs b/src/NexusReader.Data/Persistence/AppDbContext.cs index 4cd1505..57d80d5 100644 --- a/src/NexusReader.Data/Persistence/AppDbContext.cs +++ b/src/NexusReader.Data/Persistence/AppDbContext.cs @@ -55,16 +55,7 @@ public class AppDbContext : IdentityDbContext entity.HasKey(e => e.ContentHash); entity.HasIndex(e => e.ContentHash).IsUnique(); entity.HasIndex(e => e.TenantId); - if (Database.IsNpgsql()) - { - // Configure vector column (768 dims) and HNSW index for cosine similarity - entity.Property(e => e.Embedding).HasColumnType("vector(768)"); - entity.HasIndex(e => e.Embedding).HasMethod("hnsw").HasOperators("vector_cosine_ops"); - } - else - { - entity.Ignore(e => e.Embedding); - } + entity.Ignore(e => e.Embedding); }); modelBuilder.Entity(entity => diff --git a/src/NexusReader.Data/Persistence/AppDbContextFactory.cs b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs index 6454c8c..d1e954e 100644 --- a/src/NexusReader.Data/Persistence/AppDbContextFactory.cs +++ b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; -using Pgvector.EntityFrameworkCore; namespace NexusReader.Data.Persistence; @@ -38,7 +37,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres"; } - optionsBuilder.UseNpgsql(connectionString, x => x.UseVector()); + optionsBuilder.UseNpgsql(connectionString); return new AppDbContext(optionsBuilder.Options); } diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor index 3331c17..bdfb4ae 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor @@ -1,39 +1,334 @@ @namespace NexusReader.UI.Shared.Components.Atoms +@using System.Text.RegularExpressions +@using NexusReader.Application.DTOs.AI +@inject IKnowledgeService KnowledgeService +@inject IReaderNavigationService NavService +@inject IReaderInteractionService InteractionService +@inject NavigationManager NavManager +@inject AuthenticationStateProvider AuthStateProvider +@inject ILogger Logger +@implements IDisposable -
+
- - + @if (_isLoading) + { +
+ } + else + { + + } +
+ + + +
+ +
+ @if (!string.IsNullOrEmpty(SearchValue)) { - + }
+ + @if (_isDropdownOpen && (!string.IsNullOrEmpty(SearchValue) || _isLoading || _results.Any() || _searchError != null)) + { +
+ @if (_isLoading) + { + + } + else if (_searchError != null) + { + + } + else if (_results.Any()) + { + + } + else if (!string.IsNullOrEmpty(SearchValue)) + { + + } +
+ }
@code { - [Parameter] public string Placeholder { get; set; } = "Search your library..."; - [Parameter] public string IconClass { get; set; } = "bi bi-search"; + [Parameter] public string Placeholder { get; set; } = "Zapytaj swoją bibliotekę AI..."; [Parameter] public EventCallback OnSearch { get; set; } - - private string SearchValue { get; set; } = string.Empty; - private bool IsActive => !string.IsNullOrEmpty(SearchValue); + [Parameter] public int Limit { get; set; } = 5; - private async Task HandleKeyPress(KeyboardEventArgs e) + private string SearchValue { get; set; } = string.Empty; + private bool IsFocused { get; set; } + private bool HasResults => _results.Any() && _isDropdownOpen; + + private List _results = new(); + private bool _isLoading; + private string? _searchError; + private bool _isDropdownOpen; + + private CancellationTokenSource? _searchCts; + + private async Task HandleInput(ChangeEventArgs e) { - if (e.Key == "Enter") + SearchValue = e.Value?.ToString() ?? string.Empty; + _searchError = null; + + if (string.IsNullOrWhiteSpace(SearchValue)) { - await OnSearch.InvokeAsync(SearchValue); + _results.Clear(); + _isDropdownOpen = false; + return; } + + _isDropdownOpen = true; + + // Cancel previous search in-flight + _searchCts?.Cancel(); + _searchCts?.Dispose(); + _searchCts = new CancellationTokenSource(); + + var token = _searchCts.Token; + + try + { + // Debounce for 300ms + await Task.Delay(300, token); + await PerformSearchAsync(token); + } + catch (TaskCanceledException) + { + // Typing continued, search cancelled + } + } + + private async Task PerformSearchAsync(CancellationToken token) + { + _isLoading = true; + _searchError = null; + await InvokeAsync(StateHasChanged); + + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global"; + + var result = await KnowledgeService.SearchLibrarySemanticallyAsync(SearchValue, tenantId, Limit, token); + if (token.IsCancellationRequested) return; + + if (result.IsSuccess) + { + _results = result.Value ?? new List(); + _searchError = null; + } + else + { + _results.Clear(); + _searchError = result.Errors.FirstOrDefault()?.Message ?? "Nie udało się wykonać wyszukiwania."; + Logger.LogWarning("Semantic search returned errors: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message))); + } + } + catch (Exception ex) + { + if (!token.IsCancellationRequested) + { + _results.Clear(); + _searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania."; + Logger.LogError(ex, "Unexpected error during semantic search for query: {Query}", SearchValue); + } + } + finally + { + if (!token.IsCancellationRequested) + { + _isLoading = false; + await InvokeAsync(StateHasChanged); + } + } + } + + private async Task HandleResultClick(SemanticSearchResultDto result) + { + _isDropdownOpen = false; + + // 1. Resolve Ebook ID + Guid? ebookId = null; + if (result.Metadata != null) + { + foreach (var key in new[] { "ebookId", "ebook_id", "EbookId", "Ebook_Id" }) + { + if (result.Metadata.TryGetValue(key, out var val) && val != null) + { + if (Guid.TryParse(val.ToString(), out var g)) + { + ebookId = g; + break; + } + } + } + } + + if (ebookId == null || ebookId == Guid.Empty) + { + ebookId = NavService.CurrentEbookId; + } + + if (ebookId == null || ebookId == Guid.Empty) + { + Logger.LogWarning("Could not resolve ebook ID from search result metadata."); + return; + } + + // 2. Resolve Chapter Index + int chapterIndex = 0; + if (result.Metadata != null) + { + foreach (var key in new[] { "chapterIndex", "chapter_index", "ChapterIndex", "chapter" }) + { + if (result.Metadata.TryGetValue(key, out var val) && val != null) + { + if (int.TryParse(val.ToString(), out var parsedInt)) + { + chapterIndex = parsedInt; + break; + } + } + } + } + + // 3. Resolve Block ID + string? blockId = null; + if (result.Metadata != null) + { + foreach (var key in new[] { "blockId", "block_id", "BlockId", "nodeId", "node_id", "NodeId", "id" }) + { + if (result.Metadata.TryGetValue(key, out var val) && val != null) + { + blockId = val.ToString(); + break; + } + } + } + + if (string.IsNullOrEmpty(blockId)) + { + blockId = result.ContentHash; + } + + // 4. Set pending scroll and navigate + NavService.PendingScrollBlockId = blockId; + + if (NavService.CurrentEbookId == ebookId.Value && NavService.CurrentChapterIndex == chapterIndex) + { + // Same chapter - scroll and highlight immediately + if (!string.IsNullOrEmpty(blockId)) + { + await InteractionService.RequestScrollToBlock(blockId); + await InteractionService.RequestHighlightBlock(blockId); + } + } + else + { + // Different chapter or book - perform routing + NavService.SetBook(ebookId.Value, chapterIndex); + NavManager.NavigateTo($"/reader/{ebookId.Value}?chapter={chapterIndex}"); + } + + // Invoke the optional callback for parent components + await OnSearch.InvokeAsync(SearchValue); + } + + private void HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + _isDropdownOpen = false; + } + } + + private void HandleFocusIn() + { + IsFocused = true; + _isDropdownOpen = true; + } + + private async Task HandleFocusOut() + { + IsFocused = false; + // Delay slightly to allow click handlers on result cards to execute + await Task.Delay(200); + _isDropdownOpen = false; + StateHasChanged(); } private void ClearSearch() { SearchValue = string.Empty; + _results.Clear(); + _searchError = null; + _isDropdownOpen = false; + } + + private string HighlightQueryWords(string text, string query) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query)) + return text; + + var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Where(w => w.Length > 2) + .Select(Regex.Escape); + + if (!words.Any()) + return text; + + var pattern = "(" + string.Join("|", words) + ")"; + try + { + return Regex.Replace(text, pattern, "$1", RegexOptions.IgnoreCase); + } + catch + { + return text; + } + } + + public void Dispose() + { + _searchCts?.Cancel(); + _searchCts?.Dispose(); } } diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css index a850668..f0ee42b 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css @@ -1,57 +1,309 @@ .nexus-search-container { + position: relative; width: 100%; - max-width: 500px; - margin: 1rem auto; - transition: all 0.3s ease; + max-width: 600px; + margin: 1.5rem auto; + font-family: 'Inter', sans-serif; + z-index: 1000; } .search-wrapper { position: relative; display: flex; align-items: center; - background: var(--nexus-card, #141414); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 12px; - padding: 0.5rem 1rem; - transition: border-color 0.3s ease, box-shadow 0.3s ease; + background-color: #121212; + border: 1px solid rgba(138, 43, 226, 0.4); /* Neon fiolet border on blur */ + border-radius: 14px; + padding: 0.65rem 1.1rem; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 0 8px rgba(138, 43, 226, 0.1); } -.nexus-search-container.active .search-wrapper, -.search-wrapper:focus-within { - border-color: var(--nexus-neon, #00ff99); - box-shadow: 0 0 15px rgba(0, 255, 153, 0.2); +/* Focused state: glowing neon green border */ +.nexus-search-container.focused .search-wrapper { + background-color: #181818; + border-color: #00ff7f; /* Neon green focus */ + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 18px rgba(0, 255, 127, 0.35); +} + +.search-icon-container { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-right: 0.85rem; } .nexus-icon { - color: rgba(255, 255, 255, 0.5); - margin-right: 0.75rem; - font-size: 1.1rem; + color: rgba(255, 255, 255, 0.45); + font-size: 1.25rem; + transition: color 0.3s ease; +} + +.nexus-search-container.focused .nexus-icon { + color: #00ff7f; } .nexus-search-input { flex: 1; background: transparent; border: none; - color: white; - font-family: 'Inter', sans-serif; - font-size: 0.95rem; + color: #ffffff; + font-size: 1rem; + font-weight: 400; outline: none; + padding: 0; + width: 100%; } .nexus-search-input::placeholder { - color: rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.35); + font-style: italic; + transition: color 0.3s ease; +} + +.nexus-search-container.focused .nexus-search-input::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +/* Pulsing neon-green AI status indicator */ +.ai-status-indicator { + display: flex; + align-items: center; + margin: 0 0.75rem; +} + +.ai-pulse-dot { + width: 8px; + height: 8px; + background-color: #00ff7f; + border-radius: 50%; + display: inline-block; + position: relative; + box-shadow: 0 0 8px #00ff7f; +} + +.ai-pulse-dot::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: #00ff7f; + border-radius: 50%; + z-index: -1; + animation: pulse 2s infinite ease-in-out; } .clear-btn { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); - font-size: 1.2rem; + font-size: 1.5rem; + line-height: 1; cursor: pointer; - padding: 0 0.5rem; - transition: color 0.2s ease; + padding: 0 0.25rem; + margin-left: 0.5rem; + transition: color 0.2s ease, transform 0.2s ease; } .clear-btn:hover { - color: var(--nexus-neon, #00ff99); + color: #ff3b30; + transform: scale(1.1); +} + +/* Frosted glass results container */ +.search-dropdown { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + background: rgba(18, 18, 18, 0.82); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border: 1px solid rgba(138, 43, 226, 0.35); + border-radius: 14px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.7), 0 0 20px rgba(138, 43, 226, 0.15); + max-height: 420px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: rgba(138, 43, 226, 0.4) transparent; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + animation: slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +.search-dropdown::-webkit-scrollbar { + width: 6px; +} + +.search-dropdown::-webkit-scrollbar-thumb { + background: rgba(138, 43, 226, 0.4); + border-radius: 3px; +} + +.search-dropdown::-webkit-scrollbar-thumb:hover { + background: rgba(0, 255, 127, 0.5); +} + +/* In-flight spinners */ +.neon-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(0, 255, 127, 0.15); + border-top: 2px solid #00ff7f; + border-right: 2px solid #8a2be2; + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +.neon-spinner-large { + width: 32px; + height: 32px; + border: 3px solid rgba(138, 43, 226, 0.15); + border-top: 3px solid #8a2be2; + border-right: 3px solid #00ff7f; + border-radius: 50%; + animation: spin 0.8s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite; + margin-bottom: 1rem; +} + +.dropdown-state-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2.5rem 1.5rem; + text-align: center; +} + +.state-text { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.65); + font-weight: 300; +} + +.error-icon, .empty-icon { + font-size: 2rem; + margin-bottom: 0.75rem; +} + +.error-icon { + color: #ff3b30; + text-shadow: 0 0 10px rgba(255, 59, 48, 0.4); +} + +.empty-icon { + color: rgba(255, 255, 255, 0.2); +} + +/* Results Cards list */ +.dropdown-results-list { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.result-card { + padding: 0.95rem 1.1rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; +} + +.result-card:hover { + background: rgba(138, 43, 226, 0.08); + border-color: rgba(138, 43, 226, 0.35); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.result-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.8rem; +} + +.relevance-badge { + background: rgba(0, 255, 127, 0.15); + color: #00ff7f; + border: 1px solid rgba(0, 255, 127, 0.3); + border-radius: 6px; + padding: 0.15rem 0.45rem; + font-weight: 600; + letter-spacing: 0.02em; + text-shadow: 0 0 4px rgba(0, 255, 127, 0.3); +} + +.source-title { + color: rgba(255, 255, 255, 0.5); + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-title strong { + color: rgba(255, 255, 255, 0.85); +} + +.result-snippet { + font-size: 0.88rem; + line-height: 1.45; + color: rgba(255, 255, 255, 0.78); + font-weight: 300; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Markup highlights */ +::deep mark.search-highlight { + background: rgba(0, 255, 127, 0.22); + color: #00ff7f; + border-bottom: 1px solid rgba(0, 255, 127, 0.55); + padding: 0.05rem 0.15rem; + border-radius: 3px; + font-weight: 500; +} + +/* Animations */ +@keyframes pulse { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(0, 255, 127, 0.7); + } + 70% { + transform: scale(1.6); + box-shadow: 0 0 0 6px rgba(0, 255, 127, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(0, 255, 127, 0); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index bcce347..4e622c2 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -247,6 +247,17 @@ _isLoadingChapter = false; StateHasChanged(); + + if (result.IsSuccess && !string.IsNullOrEmpty(NavigationService.PendingScrollBlockId)) + { + var targetBlockId = NavigationService.PendingScrollBlockId; + NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls + + // Give the browser slightly more than one frame to render the loaded blocks + await Task.Delay(150); + await ScrollToNodeAsync(targetBlockId); + await InteractionService.RequestHighlightBlock(targetBlockId); + } } public async Task ScrollToNodeAsync(string id) diff --git a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs index 59482ed..824443f 100644 --- a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs +++ b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs @@ -6,6 +6,7 @@ public interface IReaderNavigationService int CurrentChapterIndex { get; } int TotalChapters { get; } string ChapterTitle { get; } + string? PendingScrollBlockId { get; set; } event Func? OnNavigationChanged; diff --git a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs index 4a64a23..e55dcc2 100644 --- a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs +++ b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs @@ -15,6 +15,7 @@ public class ReaderNavigationService : IReaderNavigationService public int CurrentChapterIndex { get; private set; } = 0; public int TotalChapters { get; private set; } = 1; public string ChapterTitle { get; private set; } = "Loading..."; + public string? PendingScrollBlockId { get; set; } public event Func? OnNavigationChanged; diff --git a/tests/NexusReader.Application.Tests/Queries/QueryTests.cs b/tests/NexusReader.Application.Tests/Queries/QueryTests.cs index dff732d..6fa6bfb 100644 --- a/tests/NexusReader.Application.Tests/Queries/QueryTests.cs +++ b/tests/NexusReader.Application.Tests/Queries/QueryTests.cs @@ -116,11 +116,8 @@ public class QueryTests : IDisposable public async Task SearchLibrarySemanticallyQuery_WithEmptyQueryText_ReturnsFailure() { // Arrange - var handler = new SearchLibrarySemanticallyQueryHandler( - _embeddingGeneratorMock.Object, - _dbContextFactoryMock.Object, - _pipelineProviderMock.Object, - _mapperMock.Object); + var knowledgeServiceMock = new Mock(); + var handler = new SearchLibrarySemanticallyQueryHandler(knowledgeServiceMock.Object); var query = new SearchLibrarySemanticallyQuery("", "tenant-123"); // Act @@ -132,39 +129,38 @@ public class QueryTests : IDisposable } [Fact] - public async Task SearchLibrarySemanticallyQuery_WithValidQuery_GeneratesEmbeddingAndQueriesDatabase() + public async Task SearchLibrarySemanticallyQuery_WithValidQuery_CallsKnowledgeService() { // Arrange var queryText = "test query"; var tenantId = "tenant-123"; + var expectedResponse = new List + { + new SemanticSearchResultDto + { + Snippet = "Matched content", + RelevanceScore = 0.95f, + SourceBookTitle = "Test Book" + } + }; - var mockEmbedding = new Embedding(new float[768]); - var mockResponse = new GeneratedEmbeddings>(new[] { mockEmbedding }); - _embeddingGeneratorMock.Setup(g => g.GenerateAsync( - It.Is>(s => s.Contains(queryText)), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(mockResponse); - - var handler = new SearchLibrarySemanticallyQueryHandler( - _embeddingGeneratorMock.Object, - _dbContextFactoryMock.Object, - _pipelineProviderMock.Object, - _mapperMock.Object); + var knowledgeServiceMock = new Mock(); + knowledgeServiceMock.Setup(s => s.SearchLibrarySemanticallyAsync(queryText, tenantId, 5, It.IsAny())) + .ReturnsAsync(Result.Ok(expectedResponse)); + var handler = new SearchLibrarySemanticallyQueryHandler(knowledgeServiceMock.Object); var query = new SearchLibrarySemanticallyQuery(queryText, tenantId); // Act - Func act = async () => await handler.Handle(query, CancellationToken.None); + var result = await handler.Handle(query, CancellationToken.None); - // Assert (SQLite provider will throw an execution/translation exception since CosineDistance is not supported, - // which confirms that the query built successfully and attempted execution!) - await act.Should().ThrowAsync(); + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.First().Snippet.Should().Be("Matched content"); + result.Value.First().SourceBookTitle.Should().Be("Test Book"); - _embeddingGeneratorMock.Verify(g => g.GenerateAsync( - It.Is>(s => s.Contains(queryText)), - It.IsAny(), - It.IsAny()), Times.Once); + knowledgeServiceMock.Verify(s => s.SearchLibrarySemanticallyAsync(queryText, tenantId, 5, It.IsAny()), Times.Once); } [Fact]