diff --git a/src/NexusReader.Application/Abstractions/Messaging/ISyncBroadcaster.cs b/src/NexusReader.Application/Abstractions/Messaging/ISyncBroadcaster.cs index 868063e..a996d0d 100644 --- a/src/NexusReader.Application/Abstractions/Messaging/ISyncBroadcaster.cs +++ b/src/NexusReader.Application/Abstractions/Messaging/ISyncBroadcaster.cs @@ -21,4 +21,18 @@ public interface ISyncBroadcaster DateTime timestamp, string? excludedConnectionId, CancellationToken cancellationToken = default); + + /// + /// Broadcasts real-time ingestion status updates to a specific user. + /// This is used by background workers to provide feedback during AI-intensive processing. + /// + /// The ID of the user who owns the ingestion request. + /// A human-readable status message (e.g., "Parsing chapters..."). + /// Progress percentage (0.0 to 1.0). + /// Cancellation token. + Task BroadcastIngestionProgressAsync( + string userId, + string message, + double progress, + CancellationToken cancellationToken = default); } diff --git a/src/NexusReader.Domain/Entities/Ebook.cs b/src/NexusReader.Domain/Entities/Ebook.cs index be5aaf7..079e5f2 100644 --- a/src/NexusReader.Domain/Entities/Ebook.cs +++ b/src/NexusReader.Domain/Entities/Ebook.cs @@ -40,6 +40,12 @@ public class Ebook public string? LastChapter { get; set; } public int LastChapterIndex { get; set; } = 0; + + /// + /// 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). + /// + public bool IsReadyForReading { get; set; } = false; // Relationship to NexusUser [Required] diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index beade8e..21d4d39 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -31,7 +31,8 @@ public static class DependencyInjection if (!string.IsNullOrEmpty(pgConnectionString)) { services.AddDbContextFactory(options => - options.UseNpgsql(pgConnectionString, x => x.UseVector())); + options.UseNpgsql(pgConnectionString, x => x.UseVector()), + ServiceLifetime.Scoped); // Also register a scoped DbContext for repositories that need it services.AddDbContext(options => @@ -41,7 +42,8 @@ public static class DependencyInjection { var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db"; services.AddDbContextFactory(options => - options.UseSqlite(sqliteConnectionString)); + options.UseSqlite(sqliteConnectionString), + ServiceLifetime.Scoped); services.AddDbContext(options => options.UseSqlite(sqliteConnectionString)); diff --git a/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs b/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs index 761390a..5e23e09 100644 --- a/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs +++ b/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs @@ -21,9 +21,14 @@ internal sealed class EbookRepository : IEbookRepository /// public async Task FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) { - // EF.Functions.ILike is PostgreSQL-specific; fall back to ToLower for SQLite. - // The Author table is expected to be small, so the full scan is acceptable. - // TODO: Add a case-insensitive collation to the Author.Name column in a future migration. + // Use PostgreSQL ILike for case-insensitive searching if on Npgsql provider, + // otherwise fallback to string comparison. + if (_context.Database.IsNpgsql()) + { + return await _context.Authors + .FirstOrDefaultAsync(a => EF.Functions.ILike(a.Name, name), cancellationToken); + } + return await _context.Authors .FirstOrDefaultAsync( a => a.Name.ToLower() == name.ToLower(), @@ -34,7 +39,12 @@ internal sealed class EbookRepository : IEbookRepository public void AddAuthor(Author author) => _context.Authors.Add(author); /// - public void AddEbook(Ebook ebook) => _context.Ebooks.Add(ebook); + public void AddEbook(Ebook ebook) + { + // Explicitly set the readiness flag to false upon addition + ebook.IsReadyForReading = false; + _context.Ebooks.Add(ebook); + } /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/src/NexusReader.Infrastructure/RealTime/SignalRSyncBroadcaster.cs b/src/NexusReader.Infrastructure/RealTime/SignalRSyncBroadcaster.cs index f7aefac..9a945be 100644 --- a/src/NexusReader.Infrastructure/RealTime/SignalRSyncBroadcaster.cs +++ b/src/NexusReader.Infrastructure/RealTime/SignalRSyncBroadcaster.cs @@ -25,19 +25,37 @@ internal sealed class SignalRSyncBroadcaster : ISyncBroadcaster string? excludedConnectionId, CancellationToken cancellationToken = default) { - var groupName = $"User_{userId}"; - + // Using Clients.User(userId) targeted broadcasting. + // This pushes to all of a user's connected devices across all sessions. if (!string.IsNullOrEmpty(excludedConnectionId)) { await _hubContext.Clients - .GroupExcept(groupName, excludedConnectionId) + .User(userId) .SendAsync("ProgressUpdated", pageId, timestamp, cancellationToken: cancellationToken); + + // Note: SignalR HubContext doesn't easily support 'Except' when using .User(id) + // from outside the Hub itself without custom IUserIdProvider. + // If strict exclusion is needed, we'd use groups, but requirements mandate .User(userId). } else { await _hubContext.Clients - .Group(groupName) + .User(userId) .SendAsync("ProgressUpdated", pageId, timestamp, cancellationToken: cancellationToken); } } + + /// + public async Task BroadcastIngestionProgressAsync( + string userId, + string message, + double progress, + CancellationToken cancellationToken = default) + { + // Pushes ingestion status (e.g., "Parsing chapters...") and progress (0.0-1.0) + // directly to the user's active session components (like BookIngestionModal). + await _hubContext.Clients + .User(userId) + .SendAsync("IngestionProgress", message, progress, cancellationToken: cancellationToken); + } } diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index eeb4f74..39b506d 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -7,6 +7,9 @@ using NexusReader.Application; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using NexusReader.Data.Persistence; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Domain.Entities; var builder = WebAssemblyHostBuilder.CreateDefault(args); @@ -42,10 +45,12 @@ builder.Services.AddHttpClient("NexusAPI", client => builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); -// Dummy registrations for server-only handlers to satisfy DI validation +// Real WASM implementations for application abstractions builder.Services.AddSingleton>(new ThrowingDbContextFactory()); -builder.Services.AddSingleton>>(new ThrowingEmbeddingGenerator()); -builder.Services.AddSingleton(new ThrowingBookStorageService()); +builder.Services.AddScoped>, WasmEmbeddingGenerator>(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddApplication(); builder.Services.AddScoped(); @@ -55,23 +60,5 @@ await builder.Build().RunAsync(); public class ThrowingDbContextFactory : IDbContextFactory { - 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 AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client. Use API proxies for data access."); } diff --git a/src/NexusReader.Web.Client/Services/WasmBookStorageService.cs b/src/NexusReader.Web.Client/Services/WasmBookStorageService.cs new file mode 100644 index 0000000..1ae99df --- /dev/null +++ b/src/NexusReader.Web.Client/Services/WasmBookStorageService.cs @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..6d93b85 --- /dev/null +++ b/src/NexusReader.Web.Client/Services/WasmEbookRepository.cs @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..f04eefd --- /dev/null +++ b/src/NexusReader.Web.Client/Services/WasmEmbeddingGenerator.cs @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..7b26a88 --- /dev/null +++ b/src/NexusReader.Web.Client/Services/WasmSyncBroadcaster.cs @@ -0,0 +1,44 @@ +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 d617132..e80ba92 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -20,6 +20,9 @@ using Microsoft.AspNetCore.Authentication; using System.Security.Claims; using NexusReader.Infrastructure.Services; using Stripe; +using Microsoft.Extensions.AI; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Application.Abstractions.Messaging; AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); @@ -250,6 +253,13 @@ 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) => @@ -293,6 +303,63 @@ 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); @@ -518,3 +585,8 @@ 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);