feat: implement WASM-compatible service proxies and update repository logic for ingestion state and database compatibility
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user