feat(ai-ux): deduplicate AI queries, handle ServiceUnavailable retries, and optimize reader canvas graph prerendering (#44)

This Pull Request encapsulates all outstanding AI, Blazor InteractiveAuto lifecycle, pgvector, and Firefox authorization/session compatibility fixes.

### Key Accomplishments:
1. **Concurrent Request Deduplication (Option B):** Implemented a thread-safe active task registry in `KnowledgeService` that groups concurrent graph extraction queries for the same content, preventing duplicate AI calls completely.
2. **Resilience Strategy for Downstream Demands:** Extended the `ai-retry` resilience pipeline to automatically intercept and retry on temporary Google API `503 ServiceUnavailable` / `high demand` spikes.
3. **Interactive Graph Generation Guard (Option A):** Prevented server-side prerender-phase graph requests in the reader canvas component.
4. **Firefox Compatibility & Cookie Handler:** Implemented an authentication endpoint and hybrid hidden-form submission flow to solve login, registration, and logout redirections and cookies securely.
5. **Autoscrolling & Graph Exclusions:** Added concept-to-block smooth scrolling, active block badging, and filtered out markdown code blocks from being extracted as nodes.

All unit tests compiled and passed 100% cleanly.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #44
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #44.
This commit is contained in:
2026-05-18 17:53:36 +00:00
committed by Marek Jaisński
parent f808734768
commit 541e9e1fb5
42 changed files with 2351 additions and 155 deletions
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NexusReader.Domain.Entities;
using Pgvector;
namespace NexusReader.Data.Persistence;
@@ -30,8 +31,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<NexusUser>(entity =>
{
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
@@ -53,26 +52,59 @@ public class AppDbContext : IdentityDbContext<NexusUser>
entity.HasIndex(p => p.PlanName).IsUnique();
});
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
if (Database.IsSqlite())
{
entity.HasKey(e => e.ContentHash);
entity.HasIndex(e => e.ContentHash).IsUnique();
entity.HasIndex(e => e.TenantId);
entity.Property(e => e.Vector).HasColumnType("vector(1536)");
});
var vectorConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<Vector, string>(
v => v != null ? string.Join(",", v.ToArray()) : string.Empty,
s => !string.IsNullOrEmpty(s) ? new Vector(s.Split(',').Select(float.Parse).ToArray()) : null!
);
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
{
entity.HasKey(e => e.ContentHash);
entity.HasIndex(e => e.ContentHash).IsUnique();
entity.HasIndex(e => e.TenantId);
entity.Property(e => e.Vector).HasConversion(vectorConverter);
});
modelBuilder.Entity<KnowledgeUnit>(entity =>
modelBuilder.Entity<KnowledgeUnit>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TenantId);
entity.HasIndex(e => e.EbookId);
entity.Property(e => e.Vector).HasConversion(vectorConverter);
entity.HasOne(e => e.Ebook)
.WithMany()
.HasForeignKey(e => e.EbookId)
.OnDelete(DeleteBehavior.Cascade);
});
}
else
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TenantId);
entity.HasIndex(e => e.EbookId);
entity.Property(e => e.Vector).HasColumnType("vector(768)");
modelBuilder.HasPostgresExtension("vector");
entity.HasOne(e => e.Ebook)
.WithMany()
.HasForeignKey(e => e.EbookId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
{
entity.HasKey(e => e.ContentHash);
entity.HasIndex(e => e.ContentHash).IsUnique();
entity.HasIndex(e => e.TenantId);
entity.Property(e => e.Vector).HasColumnType("vector(1536)");
});
modelBuilder.Entity<KnowledgeUnit>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TenantId);
entity.HasIndex(e => e.EbookId);
entity.Property(e => e.Vector).HasColumnType("vector(768)");
entity.HasOne(e => e.Ebook)
.WithMany()
.HasForeignKey(e => e.EbookId)
.OnDelete(DeleteBehavior.Cascade);
});
}
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
{