From c96a8d8651a7b1d43b716af6f93747ba205d6fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 18 May 2026 19:36:44 +0200 Subject: [PATCH 1/2] feat(ai-ux): deduplicate AI queries, handle ServiceUnavailable retries, and optimize reader canvas graph prerendering --- .gitignore | 2 + .../Commands/Library/IngestEbookCommand.cs | 2 + .../Library/IngestEbookCommandHandler.cs | 1 + .../Commands/Library/IngestEbookRequest.cs | 3 +- .../DTOs/User/UserProfileDto.cs | 1 + .../Queries/Library/GetMyEbooksQuery.cs | 46 ++ .../Library/SearchLibrarySemanticallyQuery.cs | 25 +- .../Queries/Reader/LocalEpubMetadata.cs | 5 + .../User/GetUserProfileQueryHandler.cs | 3 +- .../Migrations/AppDbContextModelSnapshot.cs | 3 + ...14184243_AddDescriptionToEbook.Designer.cs | 714 ++++++++++++++++++ .../20260514184243_AddDescriptionToEbook.cs | 28 + src/NexusReader.Domain/Entities/Ebook.cs | 2 + .../Configuration/AiSettings.cs | 2 +- .../DependencyInjection.cs | 9 +- .../Services/EpubMetadataExtractor.cs | 3 +- .../Services/KnowledgeService.cs | 42 +- .../Services/PromptRegistry.cs | 17 +- .../Organisms/BookIngestionModal.razor | 7 +- .../Organisms/CurrentReadingWidget.razor | 79 ++ .../Organisms/CurrentReadingWidget.razor.css | 201 +++++ .../Components/Organisms/KnowledgeGraph.razor | 2 +- .../Organisms/KnowledgeGraph.razor.css | 21 +- .../Components/Organisms/ReaderCanvas.razor | 44 +- .../Organisms/ReaderCanvas.razor.css | 102 +++ .../Pages/Account/Login.razor | 5 +- .../Pages/Account/Register.razor | 4 +- .../Pages/Dashboard.razor | 53 +- src/NexusReader.UI.Shared/Pages/Home.razor | 27 +- src/NexusReader.UI.Shared/Pages/Library.razor | 522 ++++++++++++- .../Services/IReaderNavigationService.cs | 5 + .../Services/KnowledgeCoordinator.cs | 30 +- .../Services/ReaderNavigationService.cs | 6 + .../wwwroot/js/knowledgeGraph.js | 21 +- src/NexusReader.Web/Components/App.razor | 18 + src/NexusReader.Web/Program.cs | 21 +- .../Services/GeminiEmbeddingTests.cs | 71 ++ 37 files changed, 2023 insertions(+), 124 deletions(-) create mode 100644 src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs create mode 100644 src/NexusReader.Data/Persistence/Migrations/20260514184243_AddDescriptionToEbook.Designer.cs create mode 100644 src/NexusReader.Data/Persistence/Migrations/20260514184243_AddDescriptionToEbook.cs create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor.css create mode 100644 tests/NexusReader.Application.Tests/Services/GeminiEmbeddingTests.cs diff --git a/.gitignore b/.gitignore index b523661..57f1b86 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ Thumbs.db .fake src/NexusReader.Web/nexus.db +src/NexusReader.Web/wwwroot/covers/ +src/NexusReader.Web/wwwroot/uploads/ diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs index 7026e25..4449541 100644 --- a/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommand.cs @@ -9,6 +9,7 @@ namespace NexusReader.Application.Commands.Library; /// The name of the author. /// The raw bytes of the cover image (optional). /// The raw bytes of the EPUB file. +/// The description or summary of the book (optional). /// The ID of the user owning the book. /// The tenant ID for multi-tenant isolation. Defaults to "global" for single-tenant or default usage. public record IngestEbookCommand( @@ -16,6 +17,7 @@ public record IngestEbookCommand( string AuthorName, byte[]? CoverImage, byte[] EpubData, + string? Description, string UserId, string TenantId = "global" ) : ICommand; diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs index 9c560af..0ae9e21 100644 --- a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs @@ -63,6 +63,7 @@ public class IngestEbookCommandHandler : IRequestHandler>>; + +public class GetMyEbooksQueryHandler : IRequestHandler>> +{ + private readonly IDbContextFactory _dbContextFactory; + + public GetMyEbooksQueryHandler(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task>> Handle(GetMyEbooksQuery request, CancellationToken cancellationToken) + { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var ebooks = await dbContext.Ebooks + .Where(e => e.UserId == request.UserId) + .OrderByDescending(e => e.LastReadDate ?? e.AddedDate) + .Select(e => new LastReadBookDto + { + Id = e.Id, + Title = e.Title, + Author = new AuthorDto + { + Id = e.Author.Id, + Name = e.Author.Name + }, + CoverUrl = e.CoverUrl, + Progress = e.Progress, + LastChapter = e.LastChapter ?? "Rozpoczynanie...", + LastChapterIndex = e.LastChapterIndex, + Description = e.Description + }) + .ToListAsync(cancellationToken); + + return Result.Ok(ebooks); + } +} diff --git a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs index c5781bc..2fc6370 100644 --- a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs +++ b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs @@ -38,25 +38,34 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) - .OrderBy(x => x.Vector!.CosineDistance(queryVector)) + .OrderBy(x => x.Vector!.CosineDistance(queryVector768)) .Take(request.Limit) .ToListAsync(cancellationToken); if (!candidates.Any()) { - // Fallback to legacy cache if no granular units found + // 3. Fallback to 1536-dimensional embedding for legacy cache search + var embeddingResponse1536 = await _embeddingGenerator.GenerateAsync( + new[] { request.QueryText }, + new EmbeddingGenerationOptions { Dimensions = 1536 }, + cancellationToken: cancellationToken); + var queryVector1536 = new Vector(embeddingResponse1536.First().Vector.ToArray()); + var legacyResults = await dbContext.SemanticKnowledgeCache .AsNoTracking() .Where(x => x.TenantId == request.TenantId && x.Vector != null) - .OrderBy(x => x.Vector!.CosineDistance(queryVector)) + .OrderBy(x => x.Vector!.CosineDistance(queryVector1536)) .Take(request.Limit) .ToListAsync(cancellationToken); @@ -64,7 +73,7 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler>(c.MetadataJson) diff --git a/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs index f45a7dd..f59efb4 100644 --- a/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs +++ b/src/NexusReader.Application/Queries/Reader/LocalEpubMetadata.cs @@ -19,4 +19,9 @@ public record LocalEpubMetadata /// The raw bytes of the cover image, if available. /// public byte[]? CoverImage { get; set; } + + /// + /// The description or summary of the book. + /// + public string? Description { get; set; } } diff --git a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs index 92b04f3..c98698b 100644 --- a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs @@ -47,7 +47,8 @@ public class GetUserProfileQueryHandler : IRequestHandler ur.UserId == u.Id) diff --git a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs index 9da5042..e794006 100644 --- a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs @@ -189,6 +189,9 @@ namespace NexusReader.Data.Migrations b.Property("CoverUrl") .HasColumnType("text"); + b.Property("Description") + .HasColumnType("text"); + b.Property("FilePath") .IsRequired() .HasColumnType("text"); diff --git a/src/NexusReader.Data/Persistence/Migrations/20260514184243_AddDescriptionToEbook.Designer.cs b/src/NexusReader.Data/Persistence/Migrations/20260514184243_AddDescriptionToEbook.Designer.cs new file mode 100644 index 0000000..31970bd --- /dev/null +++ b/src/NexusReader.Data/Persistence/Migrations/20260514184243_AddDescriptionToEbook.Designer.cs @@ -0,0 +1,714 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusReader.Data.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +namespace NexusReader.Data.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260514184243_AddDescriptionToEbook")] + partial class AddDescriptionToEbook + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CoverUrl") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsReadyForReading") + .HasColumnType("boolean"); + + b.Property("LastChapter") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastChapterIndex") + .HasColumnType("integer"); + + b.Property("LastReadDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Progress") + .HasColumnType("double precision"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Ebooks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EbookId") + .HasColumnType("uuid"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Vector") + .HasColumnType("vector(768)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("EbookId"); + + b.HasIndex("TenantId"); + + b.ToTable("KnowledgeUnits"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RelationType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SourceUnitId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TargetUnitId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("SourceUnitId"); + + b.HasIndex("TargetUnitId"); + + b.ToTable("KnowledgeUnitLinks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("AITokensUsed") + .HasColumnType("integer"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastAiActionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadPageId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionPlanId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionPlanId"); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalQuestions") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("QuizResults"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => + { + b.Property("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalText") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Vector") + .HasColumnType("vector(1536)"); + + b.HasKey("ContentHash"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("SemanticKnowledgeCache"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("IsUnlimitedTokens") + .HasColumnType("boolean"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("PlanName") + .IsUnique(); + + b.ToTable("SubscriptionPlans"); + + b.HasData( + new + { + Id = 1, + AITokenLimit = 5000, + IsUnlimitedTokens = false, + MonthlyPrice = 0m, + PlanName = "Free", + StripeProductId = "prod_Free789" + }, + new + { + Id = 2, + AITokenLimit = 10000, + IsUnlimitedTokens = false, + MonthlyPrice = 9.99m, + PlanName = "Basic", + StripeProductId = "prod_basic_placeholder" + }, + new + { + Id = 3, + AITokenLimit = 50000, + IsUnlimitedTokens = false, + MonthlyPrice = 19.99m, + PlanName = "Pro", + StripeProductId = "prod_pro_placeholder" + }, + new + { + Id = 4, + AITokenLimit = 1000000000, + IsUnlimitedTokens = true, + MonthlyPrice = 99.99m, + PlanName = "Enterprise", + StripeProductId = "prod_enterprise_placeholder" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.HasOne("NexusReader.Domain.Entities.Author", "Author") + .WithMany("Ebooks") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.NexusUser", "User") + .WithMany("Ebooks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook") + .WithMany() + .HasForeignKey("EbookId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Ebook"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => + { + b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit") + .WithMany("OutgoingLinks") + .HasForeignKey("SourceUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit") + .WithMany("IncomingLinks") + .HasForeignKey("TargetUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SourceUnit"); + + b.Navigation("TargetUnit"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan") + .WithMany() + .HasForeignKey("SubscriptionPlanId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SubscriptionPlan"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", "User") + .WithMany("QuizResults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Navigation("Ebooks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.Navigation("IncomingLinks"); + + b.Navigation("OutgoingLinks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.Navigation("Ebooks"); + + b.Navigation("QuizResults"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusReader.Data/Persistence/Migrations/20260514184243_AddDescriptionToEbook.cs b/src/NexusReader.Data/Persistence/Migrations/20260514184243_AddDescriptionToEbook.cs new file mode 100644 index 0000000..8fe48df --- /dev/null +++ b/src/NexusReader.Data/Persistence/Migrations/20260514184243_AddDescriptionToEbook.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Data.Persistence.Migrations +{ + /// + public partial class AddDescriptionToEbook : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Description", + table: "Ebooks", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Description", + table: "Ebooks"); + } + } +} diff --git a/src/NexusReader.Domain/Entities/Ebook.cs b/src/NexusReader.Domain/Entities/Ebook.cs index 079e5f2..8280c62 100644 --- a/src/NexusReader.Domain/Entities/Ebook.cs +++ b/src/NexusReader.Domain/Entities/Ebook.cs @@ -41,6 +41,8 @@ public class Ebook public int LastChapterIndex { get; set; } = 0; + public string? Description { get; set; } + /// /// Gets or sets a value indicating whether the ebook has been processed by the AI ingestion engine /// and is ready for reading (Knowledge Units generated). diff --git a/src/NexusReader.Infrastructure/Configuration/AiSettings.cs b/src/NexusReader.Infrastructure/Configuration/AiSettings.cs index 16b1ce7..bdfee81 100644 --- a/src/NexusReader.Infrastructure/Configuration/AiSettings.cs +++ b/src/NexusReader.Infrastructure/Configuration/AiSettings.cs @@ -6,7 +6,7 @@ public class AiSettings public string ApiKey { get; set; } = string.Empty; public string Model { get; set; } = "gemini-1.5-flash"; - public string EmbeddingModel { get; set; } = "text-embedding-004"; + public string EmbeddingModel { get; set; } = "gemini-embedding-001"; /// /// Maximum number of tokens allowed for input. diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 21d4d39..e5c3f5e 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -63,7 +63,12 @@ public static class DependencyInjection builder.AddRetry(new RetryStrategyOptions { ShouldHandle = new PredicateBuilder().Handle(ex => - ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")), + ex.Message.Contains("429") || + ex.Message.Contains("Too Many Requests") || + ex.Message.Contains("quota") || + ex.Message.Contains("503") || + ex.Message.Contains("ServiceUnavailable") || + ex.Message.Contains("demand")), BackoffType = DelayBackoffType.Exponential, UseJitter = true, MaxRetryAttempts = aiSettings.RetryAttempts, @@ -80,7 +85,7 @@ public static class DependencyInjection services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions { ApiKey = aiSettings.ApiKey, - ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004" + ModelId = aiSettings.EmbeddingModel ?? "gemini-embedding-001" })); // Application-layer service implementations diff --git a/src/NexusReader.Infrastructure/Services/EpubMetadataExtractor.cs b/src/NexusReader.Infrastructure/Services/EpubMetadataExtractor.cs index 42386f5..215e36e 100644 --- a/src/NexusReader.Infrastructure/Services/EpubMetadataExtractor.cs +++ b/src/NexusReader.Infrastructure/Services/EpubMetadataExtractor.cs @@ -19,8 +19,9 @@ public class EpubMetadataExtractor : IEpubMetadataExtractor using var bookRef = await EpubReader.OpenBookAsync(epubStream); var title = bookRef.Title ?? "Unknown Title"; var author = bookRef.Author ?? "Unknown Author"; + var description = bookRef.Description; byte[]? cover = await bookRef.ReadCoverAsync(); - return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover }); + return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover, Description = description }); } catch (Exception ex) { diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index cf94797..1ada2b1 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Collections.Concurrent; using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; @@ -29,7 +30,8 @@ public class KnowledgeService : IKnowledgeService private readonly AiSettings _settings; private readonly Tokenizer _tokenizer; private readonly ILogger _logger; - private const string PromptVersion = "1.0"; + private const string PromptVersion = "1.3"; + private static readonly ConcurrentDictionary>> _activeRequests = new(); public KnowledgeService( IChatClient chatClient, @@ -96,9 +98,27 @@ public class KnowledgeService : IKnowledgeService } } + // Deduplicate concurrent active requests for the exact same hash + var requestKey = $"{tenantId}:{hash}:{traceType}"; + var task = _activeRequests.GetOrAdd(requestKey, _ => + ExecuteAiRequestAndCacheAsync(normalizedText, tenantId, systemPrompt, traceType, ebookId, hash)); + + return await task; + } + + private async Task> ExecuteAiRequestAndCacheAsync( + string normalizedText, + string tenantId, + string systemPrompt, + string traceType, + Guid? ebookId, + string hash) + { _logger.LogInformation("[KnowledgeService] Cache Miss for {TraceType} ({Hash}). Requesting AI...", traceType, hash); try { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var options = new ChatOptions { Temperature = (float)_settings.Temperature, @@ -110,7 +130,7 @@ public class KnowledgeService : IKnowledgeService { new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, normalizedText) - }, options, cancellationToken: ct), cancellationToken); + }, options, cancellationToken: ct)); var rawResponse = response.Text?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(rawResponse)) return Result.Fail("AI returned an empty response."); @@ -129,16 +149,18 @@ public class KnowledgeService : IKnowledgeService try { var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct => - await _embeddingGenerator.GenerateAsync(new[] { normalizedText }, cancellationToken: ct), cancellationToken); + await _embeddingGenerator.GenerateAsync(new[] { normalizedText }, new EmbeddingGenerationOptions { Dimensions = 1536 }, cancellationToken: ct)); vector = embeddingResponse.First().Vector.ToArray(); } catch (Exception ex) { _logger.LogWarning(ex, "[KnowledgeService] Embedding generation failed; proceeding without vector."); - // We continue even if embedding fails, as the primary goal was knowledge extraction } // 4. Save to Cache + var cached = await dbContext.SemanticKnowledgeCache + .FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId); + var cacheEntry = new SemanticKnowledgeCache { ContentHash = hash, @@ -161,9 +183,9 @@ public class KnowledgeService : IKnowledgeService } // 5. Process structured KnowledgeUnits (Graph Expansion) - await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, cancellationToken); + await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default); - await dbContext.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(); return Result.Ok(knowledgePacket); } catch (JsonException ex) @@ -176,8 +198,14 @@ public class KnowledgeService : IKnowledgeService { return Result.Fail(new Error("Failed to extract knowledge from AI").CausedBy(ex)); } + finally + { + var requestKey = $"{tenantId}:{hash}:{traceType}"; + _activeRequests.TryRemove(requestKey, out _); + } } + private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken) { var unitIds = packet.Units.Select(u => u.Id).ToList(); @@ -217,7 +245,7 @@ public class KnowledgeService : IKnowledgeService try { var emb = await _retryPipeline.ExecuteAsync(async ct => - await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: ct), cancellationToken); + await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, new EmbeddingGenerationOptions { Dimensions = 768 }, cancellationToken: ct), cancellationToken); unit.Vector = new Vector(emb.First().Vector.ToArray()); } catch { /* Ignore embedding errors for now */ } diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index 766a75a..f456e61 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -6,6 +6,7 @@ public static class PromptRegistry "You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " + "CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " + "CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " + + "CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node (e.g., 'Code Example'). Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " + "CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " + "Schema: { " + "\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " + @@ -14,13 +15,14 @@ public static class PromptRegistry "}."; public const string GraphExtractionPrompt = - "You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " + - "CRITICAL: Restrict 'label' to a maximum of 3 words. " + - "CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points and their relationships. " + - "CRITICAL: Each paragraph in the user text starts with [ID: some-id]. Use these IDs ONLY for nodes representing the blocks. " + - "CRITICAL: All other extracted 'concept' nodes MUST have unique, slug-style IDs based on their labels (e.g., 'dependency-injection'). " + - "Include a 'current' node representing the block content itself if applicable. " + - "CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " + + "You are an expert at information architecture. Extract key concepts and paragraph mappings from the text to build a unified knowledge graph. " + + "The input text consists of several paragraphs, each starting with its unique block ID in the format '[ID: seg-X]'. " + + "Extract two types of nodes: " + + "1. Concept Nodes (group: 'concept'): Extract the main technical concepts discussed (e.g., ID: 'dependency-injection', label: 'Dependency Injection'). Max 10 concepts. Labels must be at most 3 words. " + + "2. Block Nodes (group: 'current'): For each paragraph in the input, create a node representing that paragraph where 'id' is the exact block ID (e.g., 'seg-1'), and 'label' is a brief summary of that paragraph's content (max 3 words). " + + "CRITICAL: If a paragraph is a code block, represent it as a single block node with label 'Code Example' (group: 'current'). Do NOT extract low-level code elements (like variables, classes, methods, or namespaces) as separate concept nodes. " + + "CRITICAL: Connect related concept nodes together, and connect each concept node to the block nodes ('seg-X') where it is discussed. " + + "Limit connections to a MAXIMUM of 15 most relevant links. " + "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }"; @@ -32,6 +34,7 @@ public static class PromptRegistry "You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " + "Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " + "CRITICAL: Units must be granular. " + + "CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit. Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " + "Schema: { " + "\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " + "\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " + diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index db518bf..950da9a 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -55,6 +55,10 @@ +
+ + +
@@ -205,7 +209,8 @@ Metadata.Title, Metadata.Author, Metadata.CoverImage != null ? Convert.ToBase64String(Metadata.CoverImage) : null, - Convert.ToBase64String(_epubBytes) + Convert.ToBase64String(_epubBytes), + Metadata.Description ); var response = await Http.PostAsJsonAsync("api/library/ingest", request); diff --git a/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor b/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor new file mode 100644 index 0000000..9752f31 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor @@ -0,0 +1,79 @@ +@using NexusReader.Application.DTOs.User +@using NexusReader.UI.Shared.Components.Atoms +@inject NavigationManager NavigationManager + +
+ @if (Book != null) + { +
+
+ @Book.Title +
+ +
+
+

@Book.Title

+ by @Book.Author.Name +
+ +
+
+ @Book.LastChapter + @(Book.Progress.ToString("F0"))% +
+
+
+
+
+ + @if (!string.IsNullOrEmpty(Book.Description)) + { +

+ @Book.Description +

+ } + else + { +

+ Kontynuuj odkrywanie wiedzy w książce "@Book.Title". + Twój cyfrowy asystent Nexus jest gotowy do analizy kolejnych rozdziałów i generowania interaktywnych map myśli. +

+ } + +
+ +
+
+
+ } + else + { +
+
+ +
+
+

Brak aktywnych lektur

+

Przejdź do biblioteki, aby rozpocząć przygodę z Nexus Reader.

+
+ +
+ } +
+ +@code { + [Parameter] public LastReadBookDto? Book { get; set; } + + private void HandleContinueReading() + { + if (Book != null) + { + NavigationManager.NavigateTo($"/reader/{Book.Id}?chapter={Book.LastChapterIndex}"); + } + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor.css new file mode 100644 index 0000000..9db498d --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/CurrentReadingWidget.razor.css @@ -0,0 +1,201 @@ +.current-reading-card { + width: 100%; + padding: 2rem; + overflow: hidden; +} + +.card-layout { + display: flex; + gap: 2.5rem; + align-items: flex-start; +} + +.book-cover { + width: 140px; + flex-shrink: 0; + position: relative; +} + +.book-cover img { + width: 100%; + border-radius: 12px; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: transform 0.3s ease; +} + +.book-cover:hover img { + transform: translateY(-5px); +} + +.book-details { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.25rem; + min-width: 0; /* Important for ellipsis */ +} + +.header-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.book-title { + font-family: var(--nexus-font-serif); + font-size: 1.75rem; + font-weight: 700; + color: #FFFFFF; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.author-name { + font-size: 0.9rem; + color: #A0A0A0; + font-weight: 500; +} + +.chapter-progress { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.progress-header { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + font-weight: 600; +} + +.chapter-name { + color: #FFFFFF; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.percentage { + color: var(--nexus-neon); +} + +.progress-bar-container { + height: 6px; + background: rgba(255, 255, 255, 0.05); + border-radius: 100px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: var(--nexus-neon); + box-shadow: 0 0 10px rgba(0, 255, 153, 0.4); + border-radius: 100px; + transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); +} + +.book-excerpt { + font-size: 0.95rem; + line-height: 1.6; + color: #B0B0B0; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.book-excerpt.empty { + font-style: italic; + opacity: 0.7; +} + +.actions { + margin-top: 0.5rem; +} + +.btn-nexus { + display: inline-flex; + align-items: center; + gap: 0.75rem; + padding: 0.8rem 1.5rem; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + border: none; +} + +.btn-nexus.outline { + background: transparent; + color: var(--nexus-neon); + border: 1px solid rgba(0, 255, 153, 0.3); +} + +.btn-nexus.outline:hover { + background: rgba(0, 255, 153, 0.05); + border-color: var(--nexus-neon); + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(0, 255, 153, 0.1); +} + +.btn-nexus.primary { + background: var(--nexus-neon); + color: #000; +} + +.btn-nexus.primary:hover { + transform: translateY(-2px); + filter: brightness(1.1); + box-shadow: 0 5px 15px rgba(0, 255, 153, 0.2); +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.5rem; + padding: 2rem; + text-align: center; +} + +.empty-icon { + color: var(--nexus-neon); + opacity: 0.3; + filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.2)); +} + +.empty-text h3 { + color: #FFFFFF; + margin: 0 0 0.5rem 0; +} + +.empty-text p { + color: #A0A0A0; + margin: 0; +} + +@media (max-width: 768px) { + .card-layout { + flex-direction: column; + align-items: center; + text-align: center; + gap: 1.5rem; + } + + .book-title, .chapter-name { + white-space: normal; + } + + .header-info, .chapter-progress { + align-items: center; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor index 879093a..e709228 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor @@ -9,7 +9,7 @@ @inject IKnowledgeGraphService GraphService @inject IReaderInteractionService InteractionService -
+
@if (GraphService.IsLoading || GraphService.CurrentGraphData == null) {
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css index cdd58b8..761e103 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css @@ -10,6 +10,10 @@ position: relative; } +.knowledge-graph-container.loading > ::deep svg { + display: none !important; +} + .graph-controls { position: absolute; bottom: 1.5rem; @@ -52,12 +56,27 @@ } .loading-state { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; - gap: 1.5rem; + justify-content: center; + gap: 1.25rem; color: #fff; text-align: center; + z-index: 20; + background: rgba(13, 13, 13, 0.8); + backdrop-filter: blur(8px); + padding: 2rem 1.5rem; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); + width: 85%; + max-width: 280px; + box-sizing: border-box; } .preloader-robot { diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index 1b54b06..bcce347 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -18,13 +18,14 @@
@if (ViewModel == null) { -
- @StatusMessage +
+
+ @StatusMessage
} else { -
+
@foreach (var block in ViewModel.Blocks) {
@@ -35,6 +36,16 @@
}
+ + @if (_isLoadingChapter) + { +
+
+
+ Wczytywanie kolejnego rozdziału... +
+
+ } } e.Message))); } + + _isLoadingChapter = false; + StateHasChanged(); } public async Task ScrollToNodeAsync(string id) diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css index 5d59a86..443a865 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css @@ -156,4 +156,106 @@ 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.8; } 100% { transform: scale(1); opacity: 1; } +} + +/* Chapter Loading Overlay and Spinners */ +.loading-state.full-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 400px; + gap: 1.5rem; + animation: fadeIn 0.4s ease-out; +} + +.spinner-glow { + width: 60px; + height: 60px; + border: 3px solid rgba(0, 255, 153, 0.1); + border-radius: 50%; + border-top-color: var(--nexus-neon, #00ff99); + animation: spin 1s cubic-bezier(0.55, 0.055, 0.675, 0.19) infinite; + box-shadow: 0 0 15px rgba(0, 255, 153, 0.2); +} + +.spinner-glow.small { + width: 36px; + height: 36px; + border-width: 2px; +} + +.loading-text { + color: rgba(255, 255, 255, 0.7); + font-size: 1.1rem; + letter-spacing: 0.5px; + font-weight: 500; +} + +.theme-light .loading-text { + color: rgba(0, 0, 0, 0.7); +} + +.chapter-loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + background-color: rgba(15, 23, 42, 0.15); + backdrop-filter: blur(2px); + animation: fadeIn 0.3s ease-out; +} + +.theme-light .chapter-loading-overlay { + background-color: rgba(255, 255, 255, 0.2); +} + +.loader-card { + display: flex; + align-items: center; + gap: 1.25rem; + padding: 1.25rem 2rem; + border-radius: 40px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25), 0 0 1px rgba(255, 255, 255, 0.1) inset; + animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.loader-text { + font-family: var(--nexus-font-sans, 'Inter', sans-serif); + font-weight: 500; + color: #ffffff; + font-size: 0.95rem; + letter-spacing: 0.2px; +} + +.theme-light .loader-text { + color: #0f172a; +} + +.content-blurred { + filter: blur(3px); + opacity: 0.55; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes scaleIn { + from { transform: scale(0.9) translateY(10px); opacity: 0; } + to { transform: scale(1) translateY(0); opacity: 1; } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } } \ No newline at end of file diff --git a/src/NexusReader.UI.Shared/Pages/Account/Login.razor b/src/NexusReader.UI.Shared/Pages/Account/Login.razor index 7ac70bb..f7db582 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Login.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Login.razor @@ -117,6 +117,7 @@ "ProvisioningFailed" => "Wystąpił błąd podczas przygotowywania Twojego konta.", "UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.", "LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.", + "InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.", _ => "Wystąpił nieoczekiwany błąd podczas logowania." }; } @@ -132,8 +133,8 @@ var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe); if (result.IsSuccess) { - // Trigger hidden form submission to perform cookie-based sign-in - await JS.InvokeVoidAsync("eval", "document.getElementById('nexusLoginForm').submit()"); + // Trigger hidden form submission via robust JS helper to perform cookie-based sign-in + await JS.InvokeVoidAsync("nexusAuth.submitLoginForm", "nexusLoginForm", _loginModel.Email, _loginModel.Password, _loginModel.RememberMe); } else { diff --git a/src/NexusReader.UI.Shared/Pages/Account/Register.razor b/src/NexusReader.UI.Shared/Pages/Account/Register.razor index d9f584a..01fb43a 100644 --- a/src/NexusReader.UI.Shared/Pages/Account/Register.razor +++ b/src/NexusReader.UI.Shared/Pages/Account/Register.razor @@ -94,8 +94,8 @@ var loginResult = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password); if (loginResult.IsSuccess) { - // Trigger hidden form submission to perform cookie-based sign-in - await JS.InvokeVoidAsync("eval", "document.getElementById('nexusLoginForm').submit()"); + // Trigger hidden form submission via robust JS helper to perform cookie-based sign-in + await JS.InvokeVoidAsync("nexusAuth.submitLoginForm", "nexusLoginForm", _registerModel.Email, _registerModel.Password, false); } else { diff --git a/src/NexusReader.UI.Shared/Pages/Dashboard.razor b/src/NexusReader.UI.Shared/Pages/Dashboard.razor index 44ffb79..f9a3851 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor @@ -1,6 +1,7 @@ @page "/" @using Microsoft.AspNetCore.Authorization @using NexusReader.UI.Shared.Components.Atoms +@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Services @inject IIdentityService IdentityService @inject NavigationManager NavigationManager @@ -42,57 +43,7 @@
-
- @if (_profile?.LastReadBook != null) - { -
-

Ostatnio czytane: @_profile.LastReadBook.Title

-
-
-
- Current Book -
-
-
- @_profile.LastReadBook.LastChapter -
-
-
@(_profile.LastReadBook.Progress.ToString("F1"))%
-
-
- Postęp: @(_profile.LastReadBook.Progress.ToString("F2"))% - @_profile.LastReadBook.Author.Name -
-

- Kontynuuj odkrywanie wiedzy w książce "@_profile.LastReadBook.Title". - Twój cyfrowy asystent Nexus jest gotowy do analizy kolejnych rozdziałów i generowania interaktywnych map myśli. -

-
- - -
-
-
- } - else - { -
-

Brak aktywnych lektur

-
-
-
- -
-
-

- Nie czytasz obecnie żadnej książki. Przejdź do biblioteki, aby przesłać swój pierwszy plik EPUB i rozpocząć przygodę z Nexus Reader. -

-
- -
-
-
- } -
+
diff --git a/src/NexusReader.UI.Shared/Pages/Home.razor b/src/NexusReader.UI.Shared/Pages/Home.razor index fcedb14..bf31e07 100644 --- a/src/NexusReader.UI.Shared/Pages/Home.razor +++ b/src/NexusReader.UI.Shared/Pages/Home.razor @@ -1,4 +1,5 @@ @page "/reader" +@page "/reader/{BookId:guid}" @layout ReaderLayout @attribute [Authorize] @using NexusReader.UI.Shared.Services @@ -8,6 +9,7 @@ @inject IJSRuntime JS @inject NavigationManager NavManager @inject IReaderNavigationService NavService +@inject IIdentityService IdentityService Nexus E-Reader
@@ -16,6 +18,8 @@ @code { + [Parameter] public Guid? BookId { get; set; } + private ReaderCanvas? readerCanvas; private string? _activeQuizBlockId; @@ -28,14 +32,31 @@ QuizState.OnQuizRequested += HandleQuizRequestedAsync; FocusMode.OnFocusModeChanged += HandleUpdate; await FocusMode.InitializeAsync(); + } - // Handle deep-linking to a specific chapter + protected override async Task OnParametersSetAsync() + { var uri = NavManager.ToAbsoluteUri(NavManager.Uri); + int chapterIndex = 0; if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("chapter", out var chapterValue)) { - if (int.TryParse(chapterValue, out var chapterIndex)) + int.TryParse(chapterValue, out chapterIndex); + } + + if (BookId.HasValue && BookId.Value != Guid.Empty) + { + if (NavService.CurrentEbookId != BookId.Value || NavService.CurrentChapterIndex != chapterIndex) { - await NavService.GoToChapter(chapterIndex); + NavService.SetBook(BookId.Value, chapterIndex); + } + } + else if (NavService.CurrentEbookId == Guid.Empty) + { + // If no BookId in URL and no book currently selected, try to load last read book + var profileResult = await IdentityService.GetProfileAsync(); + if (profileResult.IsSuccess && profileResult.Value.LastReadBook != null) + { + NavService.SetBook(profileResult.Value.LastReadBook.Id, chapterIndex > 0 ? chapterIndex : profileResult.Value.LastReadBook.LastChapterIndex); } } } diff --git a/src/NexusReader.UI.Shared/Pages/Library.razor b/src/NexusReader.UI.Shared/Pages/Library.razor index 0d771cc..bea0640 100644 --- a/src/NexusReader.UI.Shared/Pages/Library.razor +++ b/src/NexusReader.UI.Shared/Pages/Library.razor @@ -1,23 +1,108 @@ @page "/library" @attribute [Authorize] @using NexusReader.UI.Shared.Components.Organisms +@using NexusReader.Application.DTOs.User +@using NexusReader.UI.Shared.Services +@using System.Net.Http.Json +@inject HttpClient Http +@inject IReaderNavigationService ReaderNavigation
-

Biblioteka

+
+

Moja Biblioteka

+

Zarządzaj swoją kolekcją e-booków i rozwijaj strukturę wiedzy z Nexus AI

+
- [+] Add New Book + + Dodaj E-book
- + -
-
-

Twoja kolekcja książek i dokumentów pojawi się tutaj wkrótce.

-
+
+ @if (_isLoading) + { +
+
+
+ Wczytywanie biblioteki... +
+ +
+ @for (int i = 0; i < 3; i++) + { +
+
+
+
+
+
+
+
+ } +
+
+ } + else if (_books == null || !_books.Any()) + { +
+
+ + + + +
+

Pusta biblioteka

+

Nie masz jeszcze żadnych książek w swojej kolekcji.

+ + + + + +

Skontaktuj się z administratorem, aby dodać książki do swojego konta.

+
+
+
+ } + else + { +
+ @foreach (var book in _books) + { +
+
+ @book.Title +
+ Czytaj teraz +
+
+
+

@book.Title

+

@book.Author.Name

+ + @if (book.Progress > 0) + { +
+
+
+
+ Postęp: @(book.Progress.ToString("F0"))% (@book.LastChapter) +
+ } + else + { + Nowa + } +
+
+ } +
+ }
@@ -26,35 +111,440 @@ padding: 3rem 2rem; max-width: 1200px; margin: 0 auto; + animation: fadeIn 0.6s ease-out; } .library-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 2.5rem; + margin-bottom: 3rem; + flex-wrap: wrap; + gap: 1.5rem; } - h1 { - font-family: var(--nexus-font-serif); - font-size: 2.5rem; + .header-title-section h1 { + font-family: var(--nexus-font-serif, 'Outfit', 'Georgia', serif); + font-size: 2.8rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + background: linear-gradient(135deg, var(--nexus-text, #ffffff) 0%, rgba(var(--nexus-text-rgb, 255, 255, 255), 0.7) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: -0.5px; + } + + .header-title-section .subtitle { + font-size: 1rem; + color: rgba(255, 255, 255, 0.6); margin: 0; - color: var(--nexus-text); } - .library-content { - min-height: 400px; + .add-book-trigger { + background: linear-gradient(135deg, var(--nexus-primary, #6366f1) 0%, var(--nexus-primary-hover, #4f46e5) 100%) !important; + border: none !important; + box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4) !important; + font-weight: 600 !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; + } + + .add-book-trigger:hover { + transform: translateY(-2px) !important; + box-shadow: 0 8px 20px rgba(99, 102, 241, 0.6) !important; + } + + .btn-icon { + margin-right: 0.5rem; + font-weight: bold; + } + + /* Books Grid */ + .books-grid, .loading-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 2rem; + } + + .book-card { + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + border-radius: var(--nexus-radius-lg, 16px); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + } + + .book-card::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(800px circle at var(--x, 0) var(--y, 0), rgba(255, 255, 255, 0.06), transparent 40%); + opacity: 0; + transition: opacity 0.5s; + pointer-events: none; + } + + .book-card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3), 0 0 2px rgba(255, 255, 255, 0.1) inset; + border-color: rgba(255, 255, 255, 0.2); + } + + .book-card:hover::before { + opacity: 1; + } + + .book-cover-container { + position: relative; + height: 380px; + background: rgba(0, 0, 0, 0.2); + overflow: hidden; display: flex; align-items: center; justify-content: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); } - .empty-state { + .book-cover { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); + } + + .book-card:hover .book-cover { + transform: scale(1.08); + } + + .cover-overlay { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.6); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + backdrop-filter: blur(4px); + } + + .book-card:hover .cover-overlay { + opacity: 1; + } + + .read-action { + color: #ffffff; + font-weight: 600; + font-size: 1.1rem; + padding: 0.75rem 1.5rem; + border: 2px solid #ffffff; + border-radius: 30px; + transform: translateY(10px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .book-card:hover .read-action { + transform: translateY(0); + background: #ffffff; + color: #0f172a; + } + + .book-details { + padding: 1.5rem; + display: flex; + flex-direction: column; + flex-grow: 1; + background: rgba(15, 23, 42, 0.3); + } + + .book-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: var(--nexus-text, #ffffff); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--nexus-font-sans, 'Inter', sans-serif); + } + + .book-author { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.5); + margin: 0 0 1rem 0; + } + + .new-badge { + align-self: flex-start; + font-size: 0.75rem; + font-weight: 600; + color: var(--nexus-primary, #6366f1); + background: rgba(99, 102, 241, 0.15); + padding: 0.25rem 0.75rem; + border-radius: 20px; + border: 1px solid rgba(99, 102, 241, 0.3); + } + + /* Book Progress Bar */ + .book-progress-section { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .progress-bar { + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--nexus-primary, #6366f1) 0%, #a855f7 100%); + border-radius: 3px; + } + + .progress-text { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Empty State */ + .empty-state-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 5rem 2rem; text-align: center; - opacity: 0.6; + border-radius: var(--nexus-radius-lg, 16px); + } + + .empty-icon-pulse { + margin-bottom: 2rem; + color: rgba(255, 255, 255, 0.2); + animation: pulse 3s infinite alternate; + } + + .empty-state-container h3 { + font-family: var(--nexus-font-serif); + font-size: 1.8rem; + margin: 0 0 0.5rem 0; + color: var(--nexus-text); + } + + .empty-state-container p { + color: rgba(255, 255, 255, 0.5); + max-width: 400px; + margin: 0 0 2rem 0; + } + + .btn-nexus.primary { + background: linear-gradient(135deg, var(--nexus-primary, #6366f1) 0%, var(--nexus-primary-hover, #4f46e5) 100%); + color: #ffffff; + border: none; + padding: 0.75rem 2rem; + border-radius: 30px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4); + transition: all 0.3s ease; + } + + .btn-nexus.primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(99, 102, 241, 0.6); + } + + .restricted-info { + font-size: 0.85rem; + font-style: italic; + color: rgba(255, 255, 255, 0.35) !important; + } + + /* Skeleton Loading */ + .skeleton-card { + border-radius: var(--nexus-radius-lg, 16px); + overflow: hidden; + height: 480px; + } + + .skeleton-cover { + height: 380px; + background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; + } + + .skeleton-details { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .skeleton-line { + background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: 4px; + } + + .skeleton-line.title { + height: 20px; + width: 80%; + } + + .skeleton-line.author { + height: 14px; + width: 50%; + } + + .skeleton-line.progress { + height: 8px; + width: 100%; + margin-top: auto; + } + + .library-loading-container { + position: relative; + width: 100%; + } + + .library-loading-container .loader-card { + position: absolute; + top: 180px; + left: 50%; + transform: translate(-50%, -50%); + z-index: 10; + display: flex; + align-items: center; + gap: 1.25rem; + padding: 1.25rem 2.25rem; + border-radius: 40px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4), 0 0 1px rgba(255, 255, 255, 0.15) inset; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + } + + .spinner-glow { + width: 60px; + height: 60px; + border: 3px solid rgba(0, 255, 153, 0.1); + border-radius: 50%; + border-top-color: var(--nexus-neon, #00ff99); + animation: spin 1s cubic-bezier(0.55, 0.055, 0.675, 0.19) infinite; + box-shadow: 0 0 15px rgba(0, 255, 153, 0.2); + } + + .spinner-glow.small { + width: 28px; + height: 28px; + border-width: 2px; + } + + .loader-text { + font-family: var(--nexus-font-sans, 'Inter', sans-serif); + font-weight: 500; + color: #ffffff; + font-size: 0.95rem; + letter-spacing: 0.2px; + } + + /* Skeleton Loading enhancements */ + .skeleton-card { + background: rgba(255, 255, 255, 0.02) !important; + border: 1px solid rgba(255, 255, 255, 0.05) !important; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important; + opacity: 0.5; + } + + .skeleton-cover { + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.04) 75%) !important; + } + + .skeleton-line { + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.04) 75%) !important; + } + + /* Animations */ + @@keyframes fadeIn { + from { opacity: 0; transform: translateY(15px); } + to { opacity: 1; transform: translateY(0); } + } + + @@keyframes pulse { + 0% { transform: scale(0.95); opacity: 0.6; } + 100% { transform: scale(1.05); opacity: 0.9; } + } + + @@keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + @@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + @@keyframes scaleIn { + from { transform: translate(-50%, -50%) scale(0.9); opacity: 0; } + to { transform: translate(-50%, -50%) scale(1); opacity: 1; } } @code { private bool _isModalOpen; + private bool _isLoading = true; + private List? _books; + + protected override async Task OnInitializedAsync() + { + await LoadBooksAsync(); + } + + private async Task LoadBooksAsync() + { + _isLoading = true; + StateHasChanged(); + + try + { + _books = await Http.GetFromJsonAsync>("api/library/books"); + _isLoading = false; + } + catch (Exception ex) + { + Console.WriteLine($"[Library] Failed to load books: {ex.Message}"); + if (OperatingSystem.IsBrowser()) + { + _isLoading = false; + } + } + finally + { + StateHasChanged(); + } + } + + private async Task RefreshLibrary() + { + // Refresh when modal closes or when a book is successfully ingested + await LoadBooksAsync(); + } + + private void OpenBook(Guid bookId) + { + ReaderNavigation.NavigateToBook(bookId); + } } diff --git a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs index 897cc14..59482ed 100644 --- a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs +++ b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs @@ -18,4 +18,9 @@ public interface IReaderNavigationService /// Navigates to the reader for a specific book and records the current ebook ID. ///
void NavigateToBook(Guid bookId); + + /// + /// Sets the active book context (ID and optional chapter) without triggering browser routing. + /// + void SetBook(Guid bookId, int chapterIndex = 0); } diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs index 858512c..7121ba5 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs @@ -43,8 +43,34 @@ public sealed partial class KnowledgeCoordinator : IDisposable private async Task HandleNodeSelected(string nodeId) { - await _interactionService.RequestScrollToBlock(nodeId); - await _interactionService.RequestHighlightBlock(nodeId); + string? targetBlockId = nodeId; + + var graph = _graphService.CurrentGraphData; + if (graph != null) + { + var selectedNode = graph.Nodes.FirstOrDefault(n => n.Id == nodeId); + if (selectedNode != null && selectedNode.Group == "concept") + { + // Look for connected block nodes (group: "current") in the links + var connectedLinks = graph.Links.Where(l => l.Source == nodeId || l.Target == nodeId).ToList(); + foreach (var link in connectedLinks) + { + var otherId = link.Source == nodeId ? link.Target : link.Source; + var otherNode = graph.Nodes.FirstOrDefault(n => n.Id == otherId); + if (otherNode != null && otherNode.Group == "current") + { + targetBlockId = otherId; + break; + } + } + } + } + + if (!string.IsNullOrEmpty(targetBlockId)) + { + await _interactionService.RequestScrollToBlock(targetBlockId); + await _interactionService.RequestHighlightBlock(targetBlockId); + } } public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global") diff --git a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs index 2f52a4b..4a64a23 100644 --- a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs +++ b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs @@ -62,6 +62,12 @@ public class ReaderNavigationService : IReaderNavigationService _navigationManager.NavigateTo($"/reader/{bookId}"); } + public void SetBook(Guid bookId, int chapterIndex = 0) + { + CurrentEbookId = bookId; + CurrentChapterIndex = chapterIndex; + } + private async Task NotifyNavigationChangedAsync() { var handlers = OnNavigationChanged?.GetInvocationList(); diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index 968e052..f83f487 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -386,12 +386,19 @@ export function zoomToFit() { export function clear() { if (!rootGroup) return; - rootGroup.select(".links-layer").selectAll("path").remove(); - rootGroup.select(".nodes-layer").selectAll("g.node-group").remove(); - if (badge) badge.style("display", "none"); - if (simulation) { - simulation.nodes([]); - simulation.force("link").links([]); - simulation.stop(); + try { + rootGroup.select(".links-layer").selectAll("path").remove(); + rootGroup.select(".nodes-layer").selectAll("g.node-group").remove(); + if (badge) badge.style("display", "none"); + if (simulation) { + simulation.stop(); + const linkForce = simulation.force("link"); + if (linkForce) { + linkForce.links([]); + } + simulation.nodes([]); + } + } catch (e) { + console.warn("Failed to clear force simulation safely:", e); } } diff --git a/src/NexusReader.Web/Components/App.razor b/src/NexusReader.Web/Components/App.razor index 266dea5..f220135 100644 --- a/src/NexusReader.Web/Components/App.razor +++ b/src/NexusReader.Web/Components/App.razor @@ -41,6 +41,24 @@ // Fallback: If for some reason 'load' doesn't fire (e.g. big assets), hide after 3s anyway setTimeout(hidePreloader, 3000); })(); + + window.nexusAuth = { + submitLoginForm: function (formId, email, password, rememberMe) { + var form = document.getElementById(formId); + if (!form) return false; + + var emailInput = form.querySelector('input[name="email"]'); + var passwordInput = form.querySelector('input[name="password"]'); + var rememberMeInput = form.querySelector('input[name="rememberMe"]'); + + if (emailInput) emailInput.value = email; + if (passwordInput) passwordInput.value = password; + if (rememberMeInput) rememberMeInput.value = rememberMe ? "true" : "false"; + + form.submit(); + return true; + } + }; diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 42c36d9..1d82210 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -6,6 +6,7 @@ using NexusReader.Infrastructure; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Queries.User; using NexusReader.Application.Commands.Library; +using NexusReader.Application.Queries.Library; using MediatR; using NexusReader.Web.Client.Services; using NexusReader.UI.Shared.Services; @@ -235,6 +236,7 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); } +app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); @@ -253,7 +255,9 @@ app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int inde return Results.BadRequest(errorMsg); }).RequireAuthorization(); -var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens"); +var knowledgeApi = app.MapGroup("/api/knowledge") + .RequireAuthorization("HasAvailableTokens") + .DisableAntiforgery(); knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { @@ -319,6 +323,7 @@ app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, request.AuthorName, coverData, epubData, + request.Description, userId ); @@ -328,6 +333,18 @@ app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Ingestion failed"); }).RequireAuthorization().DisableAntiforgery(); +app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator) => +{ + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); + + var result = await mediator.Send(new GetMyEbooksQuery(userId)); + if (result.IsSuccess) return Results.Ok(result.Value); + + var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; + return Results.BadRequest(errorMsg); +}).RequireAuthorization(); + app.MapPost("/api/StripeWebhook", async ( HttpContext context, UserManager userManager, @@ -369,7 +386,7 @@ app.MapPost("/api/StripeWebhook", async ( { return Results.BadRequest(e.Message); } -}); +}).DisableAntiforgery(); async Task HandleSubscriptionSuccess( string? email, diff --git a/tests/NexusReader.Application.Tests/Services/GeminiEmbeddingTests.cs b/tests/NexusReader.Application.Tests/Services/GeminiEmbeddingTests.cs new file mode 100644 index 0000000..d0619f5 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Services/GeminiEmbeddingTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using GeminiDotnet; +using GeminiDotnet.Extensions.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace NexusReader.Application.Tests.Services; + +public class GeminiEmbeddingTests +{ + [Fact] + public async Task TestGeminiEmbedding_ModelsAndDimensions() + { + var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY"); + if (string.IsNullOrEmpty(apiKey)) + { + Console.WriteLine("Skipping test: GEMINI_API_KEY is not set."); + return; + } + + // Test Model 1: gemini-embedding-001 + try + { + var generator = new GeminiEmbeddingGenerator(new GeminiClientOptions + { + ApiKey = apiKey, + ModelId = "gemini-embedding-001" + }); + + // 1. Without dimensions (default) + var responseDefault = await generator.GenerateAsync(new[] { "Hello world" }); + var vectorDefault = responseDefault.First().Vector.ToArray(); + Console.WriteLine($"[TEST] gemini-embedding-001 default dimensions: {vectorDefault.Length}"); + + // 2. With 768 dimensions + var response768 = await generator.GenerateAsync(new[] { "Hello world" }, new EmbeddingGenerationOptions { Dimensions = 768 }); + var vector768 = response768.First().Vector.ToArray(); + Console.WriteLine($"[TEST] gemini-embedding-001 768 dimensions: {vector768.Length}"); + + // 3. With 1536 dimensions + var response1536 = await generator.GenerateAsync(new[] { "Hello world" }, new EmbeddingGenerationOptions { Dimensions = 1536 }); + var vector1536 = response1536.First().Vector.ToArray(); + Console.WriteLine($"[TEST] gemini-embedding-001 1536 dimensions: {vector1536.Length}"); + } + catch (Exception ex) + { + Console.WriteLine($"[TEST] gemini-embedding-001 failed: {ex}"); + } + + // Test Model 2: models/embedding-001 + try + { + var generator = new GeminiEmbeddingGenerator(new GeminiClientOptions + { + ApiKey = apiKey, + ModelId = "models/embedding-001" + }); + + var response = await generator.GenerateAsync(new[] { "Hello world" }); + var vector = response.First().Vector.ToArray(); + Console.WriteLine($"[TEST] models/embedding-001 default dimensions: {vector.Length}"); + } + catch (Exception ex) + { + Console.WriteLine($"[TEST] models/embedding-001 failed: {ex}"); + } + } +} -- 2.52.0 From e6b4fcffd7a482c4ff07ccc70f7610b8cb6d3dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 18 May 2026 19:52:42 +0200 Subject: [PATCH 2/2] refactor: move authentication JS to external file, add book description persistence, and implement semantic search fallback tests --- README.md | 38 ++++ .../Mappings/MappingConfig.cs | 4 +- .../Library/SearchLibrarySemanticallyQuery.cs | 84 +++++++-- .../Persistence/AppDbContext.cs | 68 +++++-- src/NexusReader.UI.Shared/wwwroot/js/auth.js | 17 ++ src/NexusReader.Web/Components/App.razor | 19 +- .../Queries/QueryTests.cs | 173 ++++++++++++++++++ 7 files changed, 350 insertions(+), 53 deletions(-) create mode 100644 README.md create mode 100644 src/NexusReader.UI.Shared/wwwroot/js/auth.js create mode 100644 tests/NexusReader.Application.Tests/Queries/QueryTests.cs diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb573bc --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# 📖 Nexus Reader + +Nexus Reader is a state-of-the-art, cross-platform Blazor .NET 10 immersive e-book reader, powered by **Native AOT**, **Clean Architecture**, **CQRS**, and interactive **D3.js Relationship Graphs** built on vector-based AI semantics. + +--- + +## ✨ Features & Architecture Highlights + +### 📁 Ingestion & Description persistence +- Extracted and persistent **book descriptions** from EPUB package metadata during book ingestion. +- The `Description` field propagates cleanly from the `Ebook` entity through Mapster to `LastReadBookDto` and `UserProfileDto`. + +### 🔗 Deep-Link Routing +- Implemented deep-link route activation: `/reader/{bookId}?chapter=N`. +- Allows instant resume of reading session coordinates and loads the specific chapter chapter directly via URL query parameters. + +### 🛡️ Downstream AI Resilience +- Standard resilience engine in `DependencyInjection.cs` utilizing the **Polly** package (`ai-retry`). +- Automatically intercepts, handles, and retries on both rate-limits (`429 Too Many Requests`) and downstream capacity overloads (`503 ServiceUnavailable` / `high demand`). + +### ⚙️ Concurrent Request Deduplication +- Multi-client InteractiveAuto Blazor circuit synchronization is backed by a thread-safe active task registry in `KnowledgeService` which ensures that identical concurrent requests await a single shared task instance, eliminating redundant LLM queries. + +--- + +## 🛠️ Build & Verification Gate + +Ensure the dotnet workload matches the active SDK, and compile the full solution utilizing: + +```bash +dotnet build NexusReader.slnx --no-restore +``` + +Run test suite: + +```bash +dotnet test --no-restore +``` diff --git a/src/NexusReader.Application/Mappings/MappingConfig.cs b/src/NexusReader.Application/Mappings/MappingConfig.cs index 861a1c7..974b41c 100644 --- a/src/NexusReader.Application/Mappings/MappingConfig.cs +++ b/src/NexusReader.Application/Mappings/MappingConfig.cs @@ -13,7 +13,8 @@ public static class MappingConfig var config = TypeAdapterConfig.GlobalSettings; config.NewConfig(); - // Roles are mapped manually in queries due to Identity structure + config.NewConfig() + .Map(dest => dest.Description, src => src.Description); services.AddSingleton(config); services.AddScoped(); @@ -21,3 +22,4 @@ public static class MappingConfig return services; } } + diff --git a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs index 2fc6370..c71b67b 100644 --- a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs +++ b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs @@ -4,8 +4,8 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using NexusReader.Application.DTOs.AI; - using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; using Pgvector; using Pgvector.EntityFrameworkCore; using System.Text.Json; @@ -46,12 +46,30 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) - .OrderBy(x => x.Vector!.CosineDistance(queryVector768)) - .Take(request.Limit) - .ToListAsync(cancellationToken); + List candidates; + bool isSqlite = dbContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite"; + + if (isSqlite) + { + var allUnits = await dbContext.KnowledgeUnits + .AsNoTracking() + .Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) + .ToListAsync(cancellationToken); + + candidates = allUnits + .OrderBy(x => CalculateCosineDistance(x.Vector!, queryVector768)) + .Take(request.Limit) + .ToList(); + } + else + { + candidates = await dbContext.KnowledgeUnits + .AsNoTracking() + .Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) + .OrderBy(x => x.Vector!.CosineDistance(queryVector768)) + .Take(request.Limit) + .ToListAsync(cancellationToken); + } if (!candidates.Any()) { @@ -62,18 +80,34 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler x.TenantId == request.TenantId && x.Vector != null) - .OrderBy(x => x.Vector!.CosineDistance(queryVector1536)) - .Take(request.Limit) - .ToListAsync(cancellationToken); + List legacyResults; + if (isSqlite) + { + var allCache = await dbContext.SemanticKnowledgeCache + .AsNoTracking() + .Where(x => x.TenantId == request.TenantId && x.Vector != null) + .ToListAsync(cancellationToken); + + legacyResults = allCache + .OrderBy(x => CalculateCosineDistance(x.Vector!, queryVector1536)) + .Take(request.Limit) + .ToList(); + } + else + { + legacyResults = await dbContext.SemanticKnowledgeCache + .AsNoTracking() + .Where(x => x.TenantId == request.TenantId && x.Vector != null) + .OrderBy(x => x.Vector!.CosineDistance(queryVector1536)) + .Take(request.Limit) + .ToListAsync(cancellationToken); + } return Result.Ok(legacyResults.Select(r => new SemanticSearchResultDto { ContentHash = r.ContentHash, Snippet = r.OriginalText, - RelevanceScore = (float)(1 - r.Vector!.CosineDistance(queryVector1536)) + RelevanceScore = (float)(1 - (isSqlite ? CalculateCosineDistance(r.Vector!, queryVector1536) : r.Vector!.CosineDistance(queryVector1536))) }).ToList()); } @@ -95,10 +129,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler>(c.MetadataJson) @@ -124,4 +158,22 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler { base.OnModelCreating(modelBuilder); - modelBuilder.HasPostgresExtension("vector"); - modelBuilder.Entity(entity => { entity.Property(u => u.LastReadPageId).HasMaxLength(255); @@ -53,26 +52,59 @@ public class AppDbContext : IdentityDbContext entity.HasIndex(p => p.PlanName).IsUnique(); }); - modelBuilder.Entity(entity => + if (Database.IsSqlite()) { - entity.HasKey(e => e.ContentHash); - entity.HasIndex(e => e.ContentHash).IsUnique(); - entity.HasIndex(e => e.TenantId); - entity.Property(e => e.Vector).HasColumnType("vector(1536)"); - }); + var vectorConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( + v => v != null ? string.Join(",", v.ToArray()) : string.Empty, + s => !string.IsNullOrEmpty(s) ? new Vector(s.Split(',').Select(float.Parse).ToArray()) : null! + ); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ContentHash); + entity.HasIndex(e => e.ContentHash).IsUnique(); + entity.HasIndex(e => e.TenantId); + entity.Property(e => e.Vector).HasConversion(vectorConverter); + }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => e.EbookId); + entity.Property(e => e.Vector).HasConversion(vectorConverter); + + entity.HasOne(e => e.Ebook) + .WithMany() + .HasForeignKey(e => e.EbookId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + else { - entity.HasKey(e => e.Id); - entity.HasIndex(e => e.TenantId); - entity.HasIndex(e => e.EbookId); - entity.Property(e => e.Vector).HasColumnType("vector(768)"); + modelBuilder.HasPostgresExtension("vector"); - entity.HasOne(e => e.Ebook) - .WithMany() - .HasForeignKey(e => e.EbookId) - .OnDelete(DeleteBehavior.Cascade); - }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ContentHash); + entity.HasIndex(e => e.ContentHash).IsUnique(); + entity.HasIndex(e => e.TenantId); + entity.Property(e => e.Vector).HasColumnType("vector(1536)"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => e.EbookId); + entity.Property(e => e.Vector).HasColumnType("vector(768)"); + + entity.HasOne(e => e.Ebook) + .WithMany() + .HasForeignKey(e => e.EbookId) + .OnDelete(DeleteBehavior.Cascade); + }); + } modelBuilder.Entity(entity => { diff --git a/src/NexusReader.UI.Shared/wwwroot/js/auth.js b/src/NexusReader.UI.Shared/wwwroot/js/auth.js new file mode 100644 index 0000000..35165cc --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/js/auth.js @@ -0,0 +1,17 @@ +window.nexusAuth = { + submitLoginForm: function (formId, email, password, rememberMe) { + var form = document.getElementById(formId); + if (!form) return false; + + var emailInput = form.querySelector('input[name="email"]'); + var passwordInput = form.querySelector('input[name="password"]'); + var rememberMeInput = form.querySelector('input[name="rememberMe"]'); + + if (emailInput) emailInput.value = email; + if (passwordInput) passwordInput.value = password; + if (rememberMeInput) rememberMeInput.value = rememberMe ? "true" : "false"; + + form.submit(); + return true; + } +}; diff --git a/src/NexusReader.Web/Components/App.razor b/src/NexusReader.Web/Components/App.razor index f220135..4d84118 100644 --- a/src/NexusReader.Web/Components/App.razor +++ b/src/NexusReader.Web/Components/App.razor @@ -41,25 +41,8 @@ // Fallback: If for some reason 'load' doesn't fire (e.g. big assets), hide after 3s anyway setTimeout(hidePreloader, 3000); })(); - - window.nexusAuth = { - submitLoginForm: function (formId, email, password, rememberMe) { - var form = document.getElementById(formId); - if (!form) return false; - - var emailInput = form.querySelector('input[name="email"]'); - var passwordInput = form.querySelector('input[name="password"]'); - var rememberMeInput = form.querySelector('input[name="rememberMe"]'); - - if (emailInput) emailInput.value = email; - if (passwordInput) passwordInput.value = password; - if (rememberMeInput) rememberMeInput.value = rememberMe ? "true" : "false"; - - form.submit(); - return true; - } - }; + diff --git a/tests/NexusReader.Application.Tests/Queries/QueryTests.cs b/tests/NexusReader.Application.Tests/Queries/QueryTests.cs new file mode 100644 index 0000000..1379eda --- /dev/null +++ b/tests/NexusReader.Application.Tests/Queries/QueryTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.AI; +using Moq; +using NexusReader.Application.DTOs.AI; +using NexusReader.Application.DTOs.User; +using NexusReader.Application.Queries.Library; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; +using Pgvector; +using Xunit; + +namespace NexusReader.Application.Tests.Queries; + +public class QueryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _contextOptions; + private readonly Mock> _dbContextFactoryMock; + private readonly Mock>> _embeddingGeneratorMock; + + public QueryTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Seed initial database schema + using var context = new AppDbContext(_contextOptions); + context.Database.EnsureCreated(); + + _dbContextFactoryMock = new Mock>(); + _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new AppDbContext(_contextOptions)); + _dbContextFactoryMock.Setup(f => f.CreateDbContext()) + .Returns(() => new AppDbContext(_contextOptions)); + + _embeddingGeneratorMock = new Mock>>(); + } + + [Fact] + public async Task GetMyEbooksQuery_WithPopulatedDescription_ReturnsCorrectDescription() + { + // Arrange + using (var context = new AppDbContext(_contextOptions)) + { + var user = new NexusUser + { + Id = "user-123", + UserName = "testuser", + Email = "test@example.com", + TenantId = "tenant-123", + SubscriptionPlanId = 1 + }; + context.Users.Add(user); + + var author = new Author { Id = 1, Name = "Adam Mickiewicz" }; + context.Authors.Add(author); + + var ebook = new Ebook + { + Id = Guid.NewGuid(), + UserId = "user-123", + Title = "Pan Tadeusz", + AuthorId = author.Id, + Description = "A Polish epic poem written by Adam Mickiewicz.", + CoverUrl = "cover.png", + Progress = 42.5, + LastChapter = "Księga I", + LastChapterIndex = 1, + AddedDate = DateTime.UtcNow, + LastReadDate = DateTime.UtcNow, + FilePath = "dummy.epub" + }; + context.Ebooks.Add(ebook); + await context.SaveChangesAsync(); + } + + var handler = new GetMyEbooksQueryHandler(_dbContextFactoryMock.Object); + var query = new GetMyEbooksQuery("user-123"); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.First().Title.Should().Be("Pan Tadeusz"); + result.Value.First().Description.Should().Be("A Polish epic poem written by Adam Mickiewicz."); + result.Value.First().Progress.Should().Be(42.5); + } + + [Fact] + public async Task SearchLibrarySemanticallyQuery_WithEmptyQueryText_ReturnsFailure() + { + // Arrange + var handler = new SearchLibrarySemanticallyQueryHandler(_dbContextFactoryMock.Object, _embeddingGeneratorMock.Object); + var query = new SearchLibrarySemanticallyQuery("", "tenant-123"); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Errors.First().Message.Should().Be("Query text cannot be empty."); + } + + [Fact] + public async Task SearchLibrarySemanticallyQuery_WithNoResults_TriggersFallback1536Embedding() + { + // Arrange + // Mock 768-dim primary embedding generator response + var embedding768 = new Embedding(new float[768]); + var mockResponse768 = new GeneratedEmbeddings>(new List> { embedding768 }); + _embeddingGeneratorMock.Setup(g => g.GenerateAsync( + It.Is>(s => s.Contains("test")), + It.Is(o => o.Dimensions == 768), + It.IsAny())) + .ReturnsAsync(mockResponse768); + + // Mock 1536-dim fallback embedding generator response + var embedding1536 = new Embedding(new float[1536]); + var mockResponse1536 = new GeneratedEmbeddings>(new List> { embedding1536 }); + _embeddingGeneratorMock.Setup(g => g.GenerateAsync( + It.Is>(s => s.Contains("test")), + It.Is(o => o.Dimensions == 1536), + It.IsAny())) + .ReturnsAsync(mockResponse1536); + + // Seed one legacy cache entry + using (var context = new AppDbContext(_contextOptions)) + { + var cacheEntry = new SemanticKnowledgeCache + { + TenantId = "tenant-123", + ContentHash = "hash-123", + OriginalText = "Fallback Cache Content Snippet", + Vector = new Vector(new float[1536]), + PromptVersion = "1", + CreatedAt = DateTime.UtcNow + }; + context.SemanticKnowledgeCache.Add(cacheEntry); + await context.SaveChangesAsync(); + } + + var handler = new SearchLibrarySemanticallyQueryHandler(_dbContextFactoryMock.Object, _embeddingGeneratorMock.Object); + var query = new SearchLibrarySemanticallyQuery("test", "tenant-123"); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.First().Snippet.Should().Be("Fallback Cache Content Snippet"); + result.Value.First().ContentHash.Should().Be("hash-123"); + } + + public void Dispose() + { + _connection.Close(); + _connection.Dispose(); + } +} -- 2.52.0