feat: implement WASM-compatible service proxies and update repository logic for ingestion state and database compatibility

This commit is contained in:
2026-05-13 20:03:55 +02:00
parent 150cbcdc29
commit 92ea11a51a
11 changed files with 330 additions and 32 deletions
@@ -21,4 +21,18 @@ public interface ISyncBroadcaster
DateTime timestamp,
string? excludedConnectionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Broadcasts real-time ingestion status updates to a specific user.
/// This is used by background workers to provide feedback during AI-intensive processing.
/// </summary>
/// <param name="userId">The ID of the user who owns the ingestion request.</param>
/// <param name="message">A human-readable status message (e.g., "Parsing chapters...").</param>
/// <param name="progress">Progress percentage (0.0 to 1.0).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task BroadcastIngestionProgressAsync(
string userId,
string message,
double progress,
CancellationToken cancellationToken = default);
}
+6
View File
@@ -41,6 +41,12 @@ public class Ebook
public int LastChapterIndex { get; set; } = 0;
/// <summary>
/// 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).
/// </summary>
public bool IsReadyForReading { get; set; } = false;
// Relationship to NexusUser
[Required]
public string UserId { get; set; } = string.Empty;
@@ -31,7 +31,8 @@ public static class DependencyInjection
if (!string.IsNullOrEmpty(pgConnectionString))
{
services.AddDbContextFactory<AppDbContext>(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<AppDbContext>(options =>
@@ -41,7 +42,8 @@ public static class DependencyInjection
{
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite(sqliteConnectionString));
options.UseSqlite(sqliteConnectionString),
ServiceLifetime.Scoped);
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(sqliteConnectionString));
@@ -21,9 +21,14 @@ internal sealed class EbookRepository : IEbookRepository
/// <inheritdoc />
public async Task<Author?> 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);
/// <inheritdoc />
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);
}
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -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);
}
}
/// <inheritdoc />
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);
}
}
+9 -22
View File
@@ -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<IHttpClientFactory>().CreateClient("NexusAPI"));
// Dummy registrations for server-only handlers to satisfy DI validation
// Real WASM implementations for application abstractions
builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbContextFactory());
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
builder.Services.AddScoped<IEmbeddingGenerator<string, Embedding<float>>, WasmEmbeddingGenerator>();
builder.Services.AddScoped<IBookStorageService, WasmBookStorageService>();
builder.Services.AddScoped<IEbookRepository, WasmEbookRepository>();
builder.Services.AddScoped<ISyncBroadcaster, WasmSyncBroadcaster>();
builder.Services.AddApplication();
builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
@@ -55,23 +60,5 @@ await builder.Build().RunAsync();
public class ThrowingDbContextFactory : IDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client.");
}
public class ThrowingEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<float>>
{
public void Dispose() { }
public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(IEnumerable<string> 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<string> SaveEbookAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string> SaveEbookAsync(Stream data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string?> SaveCoverAsync(byte[] data, string fileName) => throw new NotSupportedException(ErrorMessage);
public Task<string?> 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.");
}
@@ -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<string> 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<StorageResponse>();
return result?.Path ?? string.Empty;
}
public async Task<string> SaveEbookAsync(Stream data, string fileName)
{
using var ms = new MemoryStream();
await data.CopyToAsync(ms);
return await SaveEbookAsync(ms.ToArray(), fileName);
}
public async Task<string?> 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<StorageResponse>();
return result?.Path;
}
public async Task<string?> 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);
}
@@ -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<Author?> 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<Author>(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<Author> _stagedAuthors = new();
private readonly List<Ebook> _stagedEbooks = new();
public async Task<int> 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;
}
}
@@ -0,0 +1,33 @@
using System.Net.Http.Json;
using Microsoft.Extensions.AI;
namespace NexusReader.Web.Client.Services;
public class WasmEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<float>>
{
private readonly HttpClient _httpClient;
public WasmEmbeddingGenerator(HttpClient httpClient)
{
_httpClient = httpClient;
}
public void Dispose() { }
public async Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(
IEnumerable<string> 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<GeneratedEmbeddings<Embedding<float>>>(cancellationToken: cancellationToken);
return result ?? new GeneratedEmbeddings<Embedding<float>>();
}
public object? GetService(Type serviceType, object? serviceKey = null)
{
if (serviceType == typeof(IEmbeddingGenerator<string, Embedding<float>>)) return this;
return null;
}
}
@@ -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);
}
}
+72
View File
@@ -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<string, Embedding<float>> 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<string> Values, EmbeddingGenerationOptions? Options);