diff --git a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs index 3b0cb08..1d0d443 100644 --- a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs @@ -193,6 +193,9 @@ namespace NexusReader.Data.Migrations .IsRequired() .HasColumnType("text"); + b.Property("IsReadyForReading") + .HasColumnType("boolean"); + b.Property("LastChapter") .HasMaxLength(255) .HasColumnType("character varying(255)"); diff --git a/src/NexusReader.Data/Persistence/Migrations/20260513181743_AddEbookReadyFlag.Designer.cs b/src/NexusReader.Data/Persistence/Migrations/20260513181743_AddEbookReadyFlag.Designer.cs new file mode 100644 index 0000000..b79e4b4 --- /dev/null +++ b/src/NexusReader.Data/Persistence/Migrations/20260513181743_AddEbookReadyFlag.Designer.cs @@ -0,0 +1,703 @@ +// +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("20260513181743_AddEbookReadyFlag")] + partial class AddEbookReadyFlag + { + /// + 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("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("MetadataJson") + .HasColumnType("text"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + 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("SourceId"); + + 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.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/20260513181743_AddEbookReadyFlag.cs b/src/NexusReader.Data/Persistence/Migrations/20260513181743_AddEbookReadyFlag.cs new file mode 100644 index 0000000..eb91e32 --- /dev/null +++ b/src/NexusReader.Data/Persistence/Migrations/20260513181743_AddEbookReadyFlag.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Data.Persistence.Migrations +{ + /// + public partial class AddEbookReadyFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsReadyForReading", + table: "Ebooks", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsReadyForReading", + table: "Ebooks"); + } + } +} diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index 39b506d..dcc3569 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -45,12 +45,12 @@ builder.Services.AddHttpClient("NexusAPI", client => builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); -// Real WASM implementations for application abstractions +// Dummy registrations for server-only handlers to satisfy DI validation in WASM builder.Services.AddSingleton>(new ThrowingDbContextFactory()); -builder.Services.AddScoped>, WasmEmbeddingGenerator>(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddSingleton>>(new ThrowingEmbeddingGenerator()); +builder.Services.AddSingleton(new ThrowingBookStorageService()); +builder.Services.AddSingleton(new ThrowingEbookRepository()); +builder.Services.AddSingleton(new ThrowingSyncBroadcaster()); builder.Services.AddApplication(); builder.Services.AddScoped(); @@ -60,5 +60,42 @@ await builder.Build().RunAsync(); public class ThrowingDbContextFactory : IDbContextFactory { - public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client. Use API proxies for data access."); + public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client."); +} + +public class ThrowingEmbeddingGenerator : IEmbeddingGenerator> +{ + public void Dispose() { } + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Embedding generation cannot be used in WASM client."); + public object? GetService(Type serviceType, object? serviceKey = null) => null; +} + +public class ThrowingBookStorageService : IBookStorageService +{ + private const string ErrorMessage = "File storage operations are not supported in the WASM client. Use the API endpoint for ingestion."; + + public Task SaveEbookAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage); + public Task SaveEbookAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage); + public Task SaveCoverAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage); + public Task SaveCoverAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage); +} + +public class ThrowingEbookRepository : IEbookRepository +{ + private const string ErrorMessage = "Ebook repository operations are not supported in the WASM client. Use the API endpoint for data access."; + + public Task FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); + public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage); + public void AddEbook(Ebook ebook) => throw new NotSupportedException(ErrorMessage); + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); +} + +public class ThrowingSyncBroadcaster : ISyncBroadcaster +{ + public Task BroadcastProgressAsync(string userId, string pageId, DateTime timestamp, string? excludedConnectionId, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Real-time broadcasting can only be performed by the server."); + + public Task BroadcastIngestionProgressAsync(string userId, string message, double progress, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Real-time broadcasting can only be performed by the server."); } diff --git a/src/NexusReader.Web.Client/Services/WasmBookStorageService.cs b/src/NexusReader.Web.Client/Services/WasmBookStorageService.cs deleted file mode 100644 index 1ae99df..0000000 --- a/src/NexusReader.Web.Client/Services/WasmBookStorageService.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Net.Http.Json; -using NexusReader.Application.Abstractions.Services; - -namespace NexusReader.Web.Client.Services; - -public class WasmBookStorageService : IBookStorageService -{ - private readonly HttpClient _httpClient; - - public WasmBookStorageService(HttpClient httpClient) - { - _httpClient = httpClient; - } - - public async Task SaveEbookAsync(byte[] data, string fileName) - { - var response = await _httpClient.PostAsJsonAsync("/api/storage/save/ebook", new { data, fileName }); - response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync(); - return result?.Path ?? string.Empty; - } - - public async Task SaveEbookAsync(Stream data, string fileName) - { - using var ms = new MemoryStream(); - await data.CopyToAsync(ms); - return await SaveEbookAsync(ms.ToArray(), fileName); - } - - public async Task SaveCoverAsync(byte[] data, string fileName) - { - if (data == null || data.Length == 0) return null; - var response = await _httpClient.PostAsJsonAsync("/api/storage/save/cover", new { data, fileName }); - response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync(); - return result?.Path; - } - - public async Task SaveCoverAsync(Stream data, string fileName) - { - using var ms = new MemoryStream(); - await data.CopyToAsync(ms); - return await SaveCoverAsync(ms.ToArray(), fileName); - } - - private record StorageResponse(string Path); -} diff --git a/src/NexusReader.Web.Client/Services/WasmEbookRepository.cs b/src/NexusReader.Web.Client/Services/WasmEbookRepository.cs deleted file mode 100644 index 6d93b85..0000000 --- a/src/NexusReader.Web.Client/Services/WasmEbookRepository.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Net.Http.Json; -using NexusReader.Application.Abstractions.Persistence; -using NexusReader.Domain.Entities; - -namespace NexusReader.Web.Client.Services; - -public class WasmEbookRepository : IEbookRepository -{ - private readonly HttpClient _httpClient; - - public WasmEbookRepository(HttpClient httpClient) - { - _httpClient = httpClient; - } - - public async Task FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) - { - var response = await _httpClient.PostAsJsonAsync("/api/repository/author/find", new { name }, cancellationToken); - if (response.IsSuccessStatusCode) - { - return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - } - return null; - } - - public void AddAuthor(Author author) - { - // For a repository in WASM, we can't easily do 'void' fire-and-forget Add without a local state. - // However, we can either queue it or just do nothing if the caller expects SaveChangesAsync to handle it. - // But the common pattern for this app seems to be calling the API. - // For now, we'll assume the entity will be sent during SaveChanges or a separate command. - // Given the constraints, we'll mark it for later serialization or just throw if not supported. - // Better yet: we'll implement a 'Real' enough version that tracks changes locally. - _stagedAuthors.Add(author); - } - - public void AddEbook(Ebook ebook) - { - _stagedEbooks.Add(ebook); - } - - private readonly List _stagedAuthors = new(); - private readonly List _stagedEbooks = new(); - - public async Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - int count = 0; - foreach (var author in _stagedAuthors) - { - await _httpClient.PostAsJsonAsync("/api/repository/author/add", author, cancellationToken); - count++; - } - foreach (var ebook in _stagedEbooks) - { - await _httpClient.PostAsJsonAsync("/api/repository/ebook/add", ebook, cancellationToken); - count++; - } - - _stagedAuthors.Clear(); - _stagedEbooks.Clear(); - - await _httpClient.PostAsync("/api/repository/save", null, cancellationToken); - return count; - } -} diff --git a/src/NexusReader.Web.Client/Services/WasmEmbeddingGenerator.cs b/src/NexusReader.Web.Client/Services/WasmEmbeddingGenerator.cs deleted file mode 100644 index f04eefd..0000000 --- a/src/NexusReader.Web.Client/Services/WasmEmbeddingGenerator.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Net.Http.Json; -using Microsoft.Extensions.AI; - -namespace NexusReader.Web.Client.Services; - -public class WasmEmbeddingGenerator : IEmbeddingGenerator> -{ - private readonly HttpClient _httpClient; - - public WasmEmbeddingGenerator(HttpClient httpClient) - { - _httpClient = httpClient; - } - - public void Dispose() { } - - public async Task>> GenerateAsync( - IEnumerable values, - EmbeddingGenerationOptions? options = null, - CancellationToken cancellationToken = default) - { - var response = await _httpClient.PostAsJsonAsync("/api/ai/embeddings", new { values, options }, cancellationToken); - response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync>>(cancellationToken: cancellationToken); - return result ?? new GeneratedEmbeddings>(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - if (serviceType == typeof(IEmbeddingGenerator>)) return this; - return null; - } -} diff --git a/src/NexusReader.Web.Client/Services/WasmSyncBroadcaster.cs b/src/NexusReader.Web.Client/Services/WasmSyncBroadcaster.cs deleted file mode 100644 index 7b26a88..0000000 --- a/src/NexusReader.Web.Client/Services/WasmSyncBroadcaster.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Net.Http.Json; -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Web.Client.Services; - -public class WasmSyncBroadcaster : ISyncBroadcaster -{ - private readonly HttpClient _httpClient; - - public WasmSyncBroadcaster(HttpClient httpClient) - { - _httpClient = httpClient; - } - - public async Task BroadcastProgressAsync( - string userId, - string pageId, - DateTime timestamp, - string? excludedConnectionId, - CancellationToken cancellationToken = default) - { - await _httpClient.PostAsJsonAsync("/api/broadcaster/progress", new - { - userId, - pageId, - timestamp, - excludedConnectionId - }, cancellationToken); - } - - public async Task BroadcastIngestionProgressAsync( - string userId, - string message, - double progress, - CancellationToken cancellationToken = default) - { - await _httpClient.PostAsJsonAsync("/api/broadcaster/ingestion-progress", new - { - userId, - message, - progress - }, cancellationToken); - } -} diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index e80ba92..9d082ab 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -253,13 +253,6 @@ app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int inde return Results.BadRequest(errorMsg); }).RequireAuthorization(); -// Proxy API for AI services (Embeddings) -app.MapPost("/api/ai/embeddings", async (EmbeddingsRequest request, IEmbeddingGenerator> generator) => -{ - var result = await generator.GenerateAsync(request.Values, request.Options); - return Results.Ok(result); -}).RequireAuthorization(); - var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens"); knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => @@ -303,63 +296,6 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) => return Results.BadRequest(errorMsg); }); -// Proxy API for WASM Repository calls -var repoApi = app.MapGroup("/api/repository").RequireAuthorization(); - -repoApi.MapPost("/author/find", async (AuthorFindRequest request, IEbookRepository repo) => -{ - var author = await repo.FindAuthorByNameAsync(request.Name); - return author != null ? Results.Ok(author) : Results.NotFound(); -}); - -repoApi.MapPost("/author/add", (Author author, IEbookRepository repo) => -{ - repo.AddAuthor(author); - return Results.Ok(); -}); - -repoApi.MapPost("/ebook/add", (Ebook ebook, IEbookRepository repo) => -{ - repo.AddEbook(ebook); - return Results.Ok(); -}); - -repoApi.MapPost("/save", async (IEbookRepository repo) => -{ - await repo.SaveChangesAsync(); - return Results.Ok(); -}); - -// Proxy API for WASM Broadcaster calls -var broadcasterApi = app.MapGroup("/api/broadcaster").RequireAuthorization(); - -broadcasterApi.MapPost("/progress", async (BroadcastProgressRequest request, ISyncBroadcaster broadcaster) => -{ - await broadcaster.BroadcastProgressAsync(request.UserId, request.PageId, request.Timestamp, request.ExcludedConnectionId); - return Results.Ok(); -}); - -broadcasterApi.MapPost("/ingestion-progress", async (BroadcastIngestionProgressRequest request, ISyncBroadcaster broadcaster) => -{ - await broadcaster.BroadcastIngestionProgressAsync(request.UserId, request.Message, request.Progress); - return Results.Ok(); -}); - -// Proxy API for WASM Storage calls -var storageApi = app.MapGroup("/api/storage").RequireAuthorization(); - -storageApi.MapPost("/save/ebook", async (StorageRequest request, IBookStorageService storage) => -{ - var path = await storage.SaveEbookAsync(request.Data, request.FileName); - return Results.Ok(new { Path = path }); -}); - -storageApi.MapPost("/save/cover", async (StorageRequest request, IBookStorageService storage) => -{ - var path = await storage.SaveCoverAsync(request.Data, request.FileName); - return Results.Ok(new { Path = path }); -}); - app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); @@ -585,8 +521,3 @@ app.Run(); public record KnowledgeRequest(string Text); public record GroundednessRequest(string Answer, string Context); -public record AuthorFindRequest(string Name); -public record BroadcastProgressRequest(string UserId, string PageId, DateTime Timestamp, string? ExcludedConnectionId); -public record BroadcastIngestionProgressRequest(string UserId, string Message, double Progress); -public record StorageRequest(byte[] Data, string FileName); -public record EmbeddingsRequest(IEnumerable Values, EmbeddingGenerationOptions? Options);