feat(ui/quiz): implement real-time global chapter quiz generation, submit results to database, and display dynamic statistics on dashboard #53
@@ -13,4 +13,6 @@ public class CitationDto
|
||||
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID
|
||||
public string Snippet { get; set; } = string.Empty; // Verified text snippet from context
|
||||
public string SourceBook { get; set; } = string.Empty; // Book title or description
|
||||
public string? Author { get; set; }
|
||||
public int? PageNumber { get; set; }
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
// 1. Check Cache
|
||||
var cached = await dbContext.SemanticKnowledgeCache
|
||||
.FirstOrDefaultAsync(c => c.ContentHash == hash && (c.TenantId == tenantId || c.TenantId == "global"), cancellationToken);
|
||||
.FirstOrDefaultAsync(c => c.ContentHash == hash, cancellationToken);
|
||||
|
||||
if (cached != null && cached.PromptVersion == PromptVersion)
|
||||
{
|
||||
@@ -112,7 +112,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
|
||||
// Deduplicate concurrent active requests for the exact same hash
|
||||
var requestKey = $"{tenantId}:{hash}:{traceType}";
|
||||
var requestKey = $"{hash}:{traceType}";
|
||||
|
||||
var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
|
||||
new Lazy<Task<Result<KnowledgePacket>>>(
|
||||
@@ -184,7 +184,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
// 4. Save to Cache
|
||||
var cached = await dbContext.SemanticKnowledgeCache
|
||||
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId);
|
||||
.FirstOrDefaultAsync(c => c.ContentHash == hash);
|
||||
|
||||
var cacheEntry = new SemanticKnowledgeCache
|
||||
{
|
||||
@@ -208,7 +208,14 @@ public class KnowledgeService : IKnowledgeService
|
||||
// 5. Process structured KnowledgeUnits (Graph Expansion)
|
||||
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pgEx && pgEx.SqlState == "23505")
|
||||
{
|
||||
_logger.LogWarning("[KnowledgeService] Concurrency collision on SemanticKnowledgeCache for {Hash}; another process saved it first. Swallowing.", hash);
|
||||
}
|
||||
return Result.Ok(knowledgePacket);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
@@ -1055,15 +1062,52 @@ public class KnowledgeService : IKnowledgeService
|
||||
return Result.Fail("Failed to deserialize grounded RAG response.");
|
||||
}
|
||||
|
||||
// Hydrate book titles for citations if unknown
|
||||
// Hydrate book titles, author, and page number for citations if unknown
|
||||
foreach (var citation in groundedResult.Citations)
|
||||
{
|
||||
if (pointMap.TryGetValue(citation.CitationId, out var point) &&
|
||||
point.Payload.TryGetValue("ebookId", out var ev) &&
|
||||
Guid.TryParse(ev.StringValue, out var ebId) &&
|
||||
ebookTitles.TryGetValue(ebId, out var title))
|
||||
Guid.TryParse(ev.StringValue, out var ebId))
|
||||
{
|
||||
citation.SourceBook = title;
|
||||
if (ebookTitles.TryGetValue(ebId, out var title))
|
||||
{
|
||||
citation.SourceBook = title;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up from guidMap to get exact page number and author
|
||||
if (guidMap.TryGetValue(citation.CitationId, out var unit))
|
||||
{
|
||||
if (unit.Ebook?.Author != null)
|
||||
{
|
||||
citation.Author = unit.Ebook.Author.Name;
|
||||
}
|
||||
else if (unit.EbookId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var eb = await dbContext.Ebooks.Include(e => e.Author).FirstOrDefaultAsync(e => e.Id == unit.EbookId.Value, cancellationToken);
|
||||
if (eb?.Author != null)
|
||||
{
|
||||
citation.Author = eb.Author.Name;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(unit.MetadataJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(unit.MetadataJson);
|
||||
if (meta != null && meta.TryGetValue("page", out var pageObj) && int.TryParse(pageObj?.ToString(), out var pageVal))
|
||||
{
|
||||
citation.PageNumber = pageVal;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
|
||||
<div class="nexus-citation-container" @onmouseenter="ShowPopup" @onmouseleave="HidePopup">
|
||||
<button type="button" class="nexus-citation-trigger" aria-label="Citation source">
|
||||
<!-- Circular Neon SVG Radar Ping / Stylized Book Icon -->
|
||||
<svg class="neon-radar-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="6"></circle>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
</svg>
|
||||
<span class="pulse-ring"></span>
|
||||
</button>
|
||||
|
||||
@if (_isHovered && _citation != null)
|
||||
{
|
||||
<div class="nexus-citation-popup">
|
||||
<div class="popup-header">
|
||||
<span class="book-title"><i class="bi bi-book-half"></i> @_citation.SourceBook</span>
|
||||
@if (!string.IsNullOrEmpty(_citation.Author))
|
||||
{
|
||||
<span class="separator">•</span>
|
||||
<span class="book-author">@_citation.Author</span>
|
||||
}
|
||||
@if (_citation.PageNumber.HasValue)
|
||||
{
|
||||
<span class="separator">•</span>
|
||||
<span class="page-number">Page @_citation.PageNumber.Value</span>
|
||||
}
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<p class="citation-quote">"@_citation.Snippet"</p>
|
||||
</div>
|
||||
<div class="popup-footer">
|
||||
<span class="id-badge">ID: @SourceId.Substring(0, Math.Min(8, SourceId.Length))</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public string SourceId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public List<CitationDto>? Citations { get; set; }
|
||||
|
||||
private bool _isHovered;
|
||||
private CitationDto? _citation;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_citation = Citations?.FirstOrDefault(c => c.CitationId.Equals(SourceId, System.StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// If not found in the thread citations, provide a clean fallback so the UI never displays an empty error
|
||||
if (_citation == null)
|
||||
{
|
||||
_citation = new CitationDto
|
||||
{
|
||||
CitationId = SourceId,
|
||||
SourceBook = "Grounded Document Chunk",
|
||||
Snippet = "Context snippet retrieved from vector search node."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowPopup()
|
||||
{
|
||||
_isHovered = true;
|
||||
}
|
||||
|
||||
private void HidePopup()
|
||||
{
|
||||
_isHovered = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
.nexus-citation-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.nexus-citation-trigger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #06b6d4; /* Glowing Cyan */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nexus-citation-trigger:hover {
|
||||
color: #00ff99; /* Neon Green on hover */
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.neon-radar-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 4px currentColor);
|
||||
animation: radar-spin 8s linear infinite;
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: radar-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nexus-citation-popup {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(5px);
|
||||
width: 320px;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 16, 26, 0.9); /* Premium dark background */
|
||||
border: 1px solid rgba(6, 182, 212, 0.25); /* Cyan border */
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 12px rgba(6, 182, 212, 0.15);
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation: popup-fade-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #00ff99; /* Emerald/Neon Green micro-header */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.book-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.book-author, .page-number {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.citation-quote {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.id-badge {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes radar-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes radar-ping {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popup-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px) scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@
|
||||
private bool _isJsInitialized;
|
||||
private ElementReference _containerRef;
|
||||
private bool _isInteractive;
|
||||
private string? _currentActiveBlockId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -143,6 +144,7 @@
|
||||
[JSInvokable]
|
||||
public async Task HandleBlockReached(string blockId, string content)
|
||||
{
|
||||
_currentActiveBlockId = blockId;
|
||||
await Coordinator.OnBlockReachedAsync(blockId, content);
|
||||
|
||||
if (ViewModel != null)
|
||||
@@ -160,8 +162,15 @@
|
||||
|
||||
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
||||
{
|
||||
if (string.IsNullOrEmpty(blockId) || blockId == _currentActiveBlockId)
|
||||
{
|
||||
Logger.LogDebug("[Sync] Received progress {BlockId} is empty or matches active block. Ignoring scroll.", blockId);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInformation("[Sync] Received progress from another device: block {BlockId} at {Timestamp}", blockId, timestamp);
|
||||
|
||||
_currentActiveBlockId = blockId;
|
||||
await ScrollToNodeAsync(blockId);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
@@ -212,6 +221,7 @@
|
||||
private async Task LoadChapterAsync(int index)
|
||||
{
|
||||
await Coordinator.ClearAsync();
|
||||
_isJsInitialized = false; // Reset JS initialization to re-bind the scroll observer to new DOM elements!
|
||||
_isLoadingChapter = true;
|
||||
StatusMessage = "Wczytywanie treści...";
|
||||
StateHasChanged();
|
||||
@@ -253,6 +263,7 @@
|
||||
{
|
||||
var targetBlockId = NavigationService.PendingScrollBlockId;
|
||||
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
|
||||
_currentActiveBlockId = targetBlockId;
|
||||
|
||||
// Give the browser slightly more than one frame to render the loaded blocks
|
||||
await Task.Delay(150);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,12 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
_hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) =>
|
||||
{
|
||||
// Note: In the future we might want to receive ebookId and progress here too
|
||||
if (pageId == _lastSentPageId)
|
||||
{
|
||||
_logger.LogDebug("[Sync] Ignoring self progress update for page {PageId}.", pageId);
|
||||
return;
|
||||
}
|
||||
_lastSentPageId = pageId; // Prevent echoing back duplicate progress updates
|
||||
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
|
||||
});
|
||||
|
||||
@@ -77,6 +83,8 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
{
|
||||
if (pageId == _lastSentPageId) return Result.Ok();
|
||||
|
||||
_lastSentPageId = pageId;
|
||||
|
||||
// Proper trailing-edge debounce
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
@@ -93,7 +101,6 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
if (_hubConnection?.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, chapterIndex);
|
||||
_lastSentPageId = pageId;
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
|
||||
|
||||
Reference in New Issue
Block a user