@if (Metadata != null)
{
@@ -74,7 +79,7 @@
+
+
+
+
Nexus AI Indexing
+
@IngestionStatusMessage
+
+
@((IngestionProgressPercent * 100).ToString("F0"))%
+
+
+
@if (!string.IsNullOrEmpty(ErrorMessage))
{
@@ -118,6 +135,10 @@
private bool IsParsing { get; set; }
private bool IsVerifying { get; set; }
private bool IsIngesting { get; set; }
+ private bool IsIndexing { get; set; }
+ private string IngestionStatusMessage { get; set; } = "Initializing...";
+ private double IngestionProgressPercent { get; set; }
+ private Guid IngestedBookId { get; set; } = Guid.Empty;
private LocalEpubMetadata? Metadata { get; set; }
private string? ErrorMessage { get; set; }
private byte[]? _epubBytes;
@@ -125,8 +146,42 @@
// Allow up to 50 MB
private const long MaxFileSize = 50 * 1024 * 1024;
+ protected override async Task OnInitializedAsync()
+ {
+ await SyncService.InitializeAsync();
+ SyncService.OnIngestionProgressReceived += HandleIngestionProgress;
+ }
+
+ private async Task HandleIngestionProgress(string message, double progress)
+ {
+ if (!IsIndexing) return;
+
+ IngestionStatusMessage = message;
+ IngestionProgressPercent = progress;
+
+ await InvokeAsync(StateHasChanged);
+
+ if (progress >= 1.0)
+ {
+ // Give the user a moment to see the completion message
+ await Task.Delay(2500);
+
+ // Now close the modal and navigate to the book
+ if (IngestedBookId != Guid.Empty)
+ {
+ var bookId = IngestedBookId;
+ await InvokeAsync(async () => {
+ await CloseModal();
+ ReaderNavigation.NavigateToBook(bookId);
+ });
+ }
+ }
+ }
+
private async Task CloseModal()
{
+ if (IsIngesting || IsIndexing) return;
+
IsOpen = false;
Reset();
await IsOpenChanged.InvokeAsync(false);
@@ -137,6 +192,10 @@
IsParsing = false;
IsVerifying = false;
IsIngesting = false;
+ IsIndexing = false;
+ IngestionStatusMessage = "Initializing...";
+ IngestionProgressPercent = 0.0;
+ IngestedBookId = Guid.Empty;
Metadata = null;
ErrorMessage = null;
_isDragging = false;
@@ -220,33 +279,40 @@
var result = await response.Content.ReadFromJsonAsync
();
if (result != null)
{
- await CloseModal();
- ReaderNavigation.NavigateToBook(result.Id);
+ IngestedBookId = result.Id;
+ IsVerifying = false;
+ IsIngesting = false;
+ IsIndexing = true;
+ IngestionStatusMessage = "Book saved! Starting background indexing...";
+ IngestionProgressPercent = 0.0;
+ StateHasChanged();
}
}
else
{
ErrorMessage = await response.Content.ReadAsStringAsync();
+ IsIngesting = false;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error during ingestion");
ErrorMessage = "Failed to save book to library. Please try again.";
+ IsIngesting = false;
}
finally
{
- IsIngesting = false;
StateHasChanged();
}
}
private record IngestResult(Guid Id);
- public ValueTask DisposeAsync()
+ public async ValueTask DisposeAsync()
{
+ SyncService.OnIngestionProgressReceived -= HandleIngestionProgress;
// Clear the large byte array so it is eligible for GC even if the component is cached.
_epubBytes = null;
- return ValueTask.CompletedTask;
+ await ValueTask.CompletedTask;
}
}
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
index 64f412f..519639b 100644
--- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
+++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
@@ -377,6 +377,72 @@
animation: spin 0.8s linear infinite;
}
+/* Indexing State */
+.indexing-state {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.02);
+ position: relative;
+ overflow: hidden;
+ padding: 2rem;
+ animation: fadeIn 0.4s ease-out;
+}
+
+.indexing-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ width: 100%;
+ gap: 1.25rem;
+}
+
+.indexing-content h3 {
+ margin: 0;
+ font-size: 1.25rem;
+ color: var(--nexus-neon, #00ffaa);
+ text-shadow: 0 0 10px rgba(0, 255, 153, 0.2);
+ letter-spacing: 0.5px;
+}
+
+.status-msg {
+ margin: 0;
+ font-size: 0.9rem;
+ color: var(--nexus-text-muted, #888);
+ min-height: 2.5rem;
+ line-height: 1.4;
+}
+
+.progress-bar-container {
+ width: 100%;
+ height: 8px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 4px;
+ overflow: hidden;
+ position: relative;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--nexus-neon, #00ffaa) 0%, #00b3ff 100%);
+ box-shadow: 0 0 10px rgba(0, 255, 153, 0.4);
+ border-radius: 4px;
+ transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.percent {
+ font-family: var(--nexus-font-mono, monospace);
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--nexus-text);
+}
+
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor
index 4e622c2..c34e6fd 100644
--- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor
+++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor
@@ -211,6 +211,7 @@
private async Task LoadChapterAsync(int index)
{
+ await Coordinator.ClearAsync();
_isLoadingChapter = true;
StatusMessage = "Wczytywanie treści...";
StateHasChanged();
diff --git a/src/NexusReader.UI.Shared/Services/ISyncService.cs b/src/NexusReader.UI.Shared/Services/ISyncService.cs
index adcec95..8fce9aa 100644
--- a/src/NexusReader.UI.Shared/Services/ISyncService.cs
+++ b/src/NexusReader.UI.Shared/Services/ISyncService.cs
@@ -7,5 +7,6 @@ public interface ISyncService
Task InitializeAsync();
Task UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle, int chapterIndex);
event Func OnProgressReceived;
+ event Func? OnIngestionProgressReceived;
Task DisposeAsync();
}
diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
index 7121ba5..619cd4e 100644
--- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
+++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
@@ -94,11 +94,15 @@ public sealed partial class KnowledgeCoordinator : IDisposable
if (OnGraphUpdated != null)
await OnGraphUpdated.Invoke(packet.Graph);
await _platformService.VibrateSuccessAsync();
+ return;
}
}
+
+ await _graphService.SetLoading(false);
}
catch (Exception ex)
{
+ await _graphService.SetLoading(false);
LogGraphError(ex, tenantId);
}
}
diff --git a/src/NexusReader.UI.Shared/Services/SyncService.cs b/src/NexusReader.UI.Shared/Services/SyncService.cs
index 16c986f..214d7ef 100644
--- a/src/NexusReader.UI.Shared/Services/SyncService.cs
+++ b/src/NexusReader.UI.Shared/Services/SyncService.cs
@@ -16,6 +16,7 @@ public class SyncService : ISyncService, IAsyncDisposable
private CancellationTokenSource? _debounceCts;
public event Func? OnProgressReceived;
+ public event Func? OnIngestionProgressReceived;
public SyncService(
HttpClient httpClient,
@@ -53,6 +54,11 @@ public class SyncService : ISyncService, IAsyncDisposable
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
});
+ _hubConnection.On("IngestionProgress", async (message, progress) =>
+ {
+ if (OnIngestionProgressReceived != null) await OnIngestionProgressReceived(message, progress);
+ });
+
try
{
await _hubConnection.StartAsync();
diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js
index f83f487..7356915 100644
--- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js
+++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js
@@ -3,6 +3,110 @@ import * as d3 from 'https://esm.sh/d3@7';
const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label;
const getPillWidth = d => getDisplayLabel(d).length * 8 + 30;
+const getNodeType = d => {
+ if (d) {
+ if (d.type) {
+ const t = d.type.toLowerCase();
+ if (t === 'definition') return 'definition';
+ if (t === 'table') return 'table';
+ if (t === 'rule') return 'rule';
+ if (t === 'section') return 'section';
+ }
+ if (d.group) {
+ const g = d.group.toLowerCase();
+ if (g === 'definition') return 'definition';
+ if (g === 'table') return 'table';
+ if (g === 'rule') return 'rule';
+ if (g === 'section') return 'section';
+ }
+ }
+ return null;
+};
+
+const getNodeGroup = d => {
+ if (d && d.group) {
+ const g = d.group.toLowerCase();
+ if (g === 'bridge') return 'bridge';
+ if (g === 'current') return 'current';
+ if (g === 'concept') return 'concept';
+ }
+ return 'concept'; // fallback
+};
+
+const getCategoryStyle = d => {
+ const type = getNodeType(d);
+ const group = getNodeGroup(d);
+
+ // 1. Rule (red/coral)
+ if (type === 'rule') {
+ return {
+ color: '#ff4646',
+ fill: 'rgba(255, 70, 70, 0.1)',
+ opacity: 0.8,
+ glowKey: 'rule',
+ textColor: '#ff8b8b'
+ };
+ }
+ // 2. Definition (gold/amber)
+ if (type === 'definition') {
+ return {
+ color: '#ffb03a',
+ fill: 'rgba(255, 176, 58, 0.1)',
+ opacity: 0.8,
+ glowKey: 'definition',
+ textColor: '#ffd18c'
+ };
+ }
+ // 3. Table (purple/magenta)
+ if (type === 'table') {
+ return {
+ color: '#d946ef',
+ fill: 'rgba(217, 70, 239, 0.1)',
+ opacity: 0.8,
+ glowKey: 'table',
+ textColor: '#f5d0fe'
+ };
+ }
+ // 4. Section (blue/indigo)
+ if (type === 'section') {
+ return {
+ color: '#3b82f6',
+ fill: 'rgba(59, 130, 246, 0.1)',
+ opacity: 0.8,
+ glowKey: 'section',
+ textColor: '#93c5fd'
+ };
+ }
+ // 5. Bridge (cyan/comparison)
+ if (group === 'bridge') {
+ return {
+ color: '#06b6d4',
+ fill: 'rgba(6, 182, 212, 0.1)',
+ opacity: 0.7,
+ glowKey: 'bridge',
+ textColor: '#67e8f9'
+ };
+ }
+ // 6. Current (active/focus landmark - neon green)
+ if (group === 'current') {
+ return {
+ color: 'var(--nexus-neon)',
+ fill: 'rgba(0, 255, 153, 0.15)',
+ opacity: 0.9,
+ glowKey: 'current',
+ textColor: '#ffffff'
+ };
+ }
+ // 7. Concept / Default (subtle cool steel blue/teal)
+ return {
+ color: '#00d2c4',
+ fill: 'rgba(0, 210, 196, 0.05)',
+ opacity: 0.4,
+ glowKey: 'concept',
+ textColor: '#e0e0e0'
+ };
+};
+
let simulation;
let zoomBehavior;
let svgElement;
@@ -24,8 +128,10 @@ export function mount(containerId, data, dotNetHelper) {
.attr("height", "100%")
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
- // Radial gradient for Nebula effect
+ // Radial gradients for Nebula effects
const defs = svgElement.append("defs");
+
+ // Fallback radial gradient for legacy nebulaGlow
const radialGradient = defs.append("radialGradient")
.attr("id", "nebulaGlow")
.attr("cx", "50%")
@@ -34,6 +140,26 @@ export function mount(containerId, data, dotNetHelper) {
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
+ const colors = {
+ 'rule': '#ff4646',
+ 'definition': '#ffb03a',
+ 'table': '#d946ef',
+ 'section': '#3b82f6',
+ 'bridge': '#06b6d4',
+ 'current': 'var(--nexus-neon)',
+ 'concept': '#00d2c4'
+ };
+
+ Object.entries(colors).forEach(([key, color]) => {
+ const radGrad = defs.append("radialGradient")
+ .attr("id", `nebulaGlow-${key}`)
+ .attr("cx", "50%")
+ .attr("cy", "50%")
+ .attr("r", "50%");
+ radGrad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 1);
+ radGrad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0);
+ });
+
// Root Group for Zoom
rootGroup = svgElement.append("g").attr("class", "zoom-containment");
@@ -135,21 +261,33 @@ export function updateData(data) {
}
});
+ // Sanitize links to filter out any references to non-existent nodes
+ const nodeIds = new Set(data.nodes.map(n => n.id));
+ const validLinks = (data.links || []).filter(l => {
+ const srcId = typeof l.source === 'object' ? l.source.id : l.source;
+ const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
+ return nodeIds.has(srcId) && nodeIds.has(tgtId);
+ });
+
// Update Links
link = rootGroup.select(".links-layer")
.selectAll("path")
- .data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
+ .data(validLinks, d => {
+ const srcId = typeof d.source === 'object' ? d.source.id : d.source;
+ const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
+ return srcId + "-" + tgtId + "-" + d.type;
+ })
.join(
enter => enter.append("path")
.attr("stroke", d => {
- if (d.relationType === 'Defines') return 'var(--nexus-accent)';
- if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)';
- if (d.relationType === 'Contains') return 'var(--nexus-neon)';
+ if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)';
+ if (d.type === 'Next' || d.type === 'relates_to') return 'rgba(255,255,255,0.2)';
+ if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)';
return 'rgba(255,255,255,0.1)';
})
.attr("fill", "none")
- .attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
- .attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
+ .attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1)
+ .attr("stroke-dasharray", d => d.type === 'References' ? "5,5" : "0")
.style("opacity", 0)
.call(enter => enter.transition().duration(500).style("opacity", 1)),
update => update,
@@ -174,13 +312,8 @@ export function updateData(data) {
g.append("circle")
.attr("r", 30)
- .attr("fill", d => {
- if (d.type === 'Definition') return 'var(--nexus-accent)';
- if (d.type === 'Table') return 'var(--nexus-neon)';
- if (d.type === 'Rule') return '#ff4444';
- return "url(#nebulaGlow)";
- })
- .attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
+ .attr("fill", d => `url(#nebulaGlow-${getCategoryStyle(d).glowKey})`)
+ .attr("opacity", d => getCategoryStyle(d).opacity);
g.append("rect")
.attr("class", "node-pill")
@@ -189,23 +322,20 @@ export function updateData(data) {
.attr("width", d => getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
- .attr("fill", "rgba(20, 20, 20, 0.9)")
- .attr("stroke", d => {
- if (d.type === 'Definition') return 'var(--nexus-accent)';
- if (d.type === 'Rule') return '#ff4444';
- return "rgba(255, 255, 255, 0.1)";
- })
- .attr("stroke-width", 1);
+ .attr("fill", "rgba(20, 20, 20, 0.95)")
+ .attr("stroke", d => getCategoryStyle(d).color)
+ .attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
g.append("text")
.text(d => getDisplayLabel(d))
.attr("text-anchor", "middle")
.attr("y", 5)
- .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
- .attr("font-size", "0.8rem");
+ .attr("fill", d => getCategoryStyle(d).textColor)
+ .attr("font-size", "0.8rem")
+ .attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
g.append("title")
- .text(d => d.label);
+ .text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
g.transition().duration(500).style("opacity", 1);
@@ -216,7 +346,7 @@ export function updateData(data) {
);
simulation.nodes(data.nodes);
- simulation.force("link").links(data.links);
+ simulation.force("link").links(validLinks);
simulation.alpha(0.5).restart();
// Trigger zoom to fit after a short delay to allow simulation to settle
@@ -398,6 +528,15 @@ export function clear() {
}
simulation.nodes([]);
}
+
+ // Reset selections
+ link = null;
+ node = null;
+
+ // Reset D3 zoom transform to clean identity state
+ if (svgElement && zoomBehavior) {
+ svgElement.call(zoomBehavior.transform, d3.zoomIdentity);
+ }
} catch (e) {
console.warn("Failed to clear force simulation safely:", e);
}
diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs
index dcc3569..bf431b7 100644
--- a/src/NexusReader.Web.Client/Program.cs
+++ b/src/NexusReader.Web.Client/Program.cs
@@ -51,6 +51,7 @@ builder.Services.AddSingleton>>(new
builder.Services.AddSingleton(new ThrowingBookStorageService());
builder.Services.AddSingleton(new ThrowingEbookRepository());
builder.Services.AddSingleton(new ThrowingSyncBroadcaster());
+builder.Services.AddSingleton(new ThrowingEpubExtractor());
builder.Services.AddApplication();
builder.Services.AddScoped();
@@ -99,3 +100,9 @@ public class ThrowingSyncBroadcaster : ISyncBroadcaster
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.");
}
+
+public class ThrowingEpubExtractor : IEpubExtractor
+{
+ public Task>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
+}
diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs
index 8123f92..56230f7 100644
--- a/src/NexusReader.Web/Program.cs
+++ b/src/NexusReader.Web/Program.cs
@@ -194,6 +194,7 @@ using (var scope = app.Services.CreateScope())
await dbContext.Database.MigrateAsync();
await DbInitializer.SeedAsync(services);
+ await TriggerBackgroundProcessingForUnindexedBooksAsync(services);
if (logger.IsEnabled(LogLevel.Information))
{
@@ -337,13 +338,16 @@ app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request,
? Convert.FromBase64String(request.CoverImageBase64)
: null;
+ var tenantId = user.FindFirst("TenantId")?.Value ?? "global";
+
var command = new IngestEbookCommand(
request.Title,
request.AuthorName,
coverData,
epubData,
request.Description,
- userId
+ userId,
+ tenantId
);
var result = await mediator.Send(command);
@@ -563,6 +567,50 @@ app.MapRazorComponents()
app.Run();
+async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider services)
+{
+ var logger = services.GetRequiredService>();
+ try
+ {
+ var dbContextFactory = services.GetRequiredService>();
+ using var dbContext = await dbContextFactory.CreateDbContextAsync();
+
+ var unindexedEbooks = await dbContext.Ebooks
+ .Where(e => !e.IsReadyForReading)
+ .ToListAsync();
+
+ if (unindexedEbooks.Any())
+ {
+ logger.LogInformation("[Startup] Found {Count} un-indexed ebooks. Triggering background processing...", unindexedEbooks.Count);
+
+ foreach (var ebook in unindexedEbooks)
+ {
+ logger.LogInformation("[Startup] Queuing background processing for ebook: '{Title}' ({Id})", ebook.Title, ebook.Id);
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ using var scope = services.CreateScope();
+ var scopedMediator = scope.ServiceProvider.GetRequiredService();
+ await scopedMediator.Send(new ProcessEbookCommand(ebook.Id, ebook.UserId, ebook.TenantId));
+ }
+ catch (Exception ex)
+ {
+ using var scope = services.CreateScope();
+ var scopedLogger = scope.ServiceProvider.GetRequiredService>();
+ scopedLogger.LogError(ex, "Failed to run background processing for ebook {EbookId} on startup", ebook.Id);
+ }
+ });
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error checking or triggering background processing for unindexed books on startup.");
+ }
+}
+
public record KnowledgeRequest(string Text, Guid? EbookId = null);
public record GroundednessRequest(string Answer, string Context);
public record SemanticSearchRequest(string QueryText, int Limit = 5);