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);