feat: implement interactive citation markers with metadata and optimize knowledge caching with concurrent collision handling

This commit is contained in:
2026-05-26 13:43:05 +02:00
parent 824b4366e0
commit 75c7b2f279
7 changed files with 830 additions and 314 deletions
@@ -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);
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,18 +1062,55 @@ 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))
{
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 { }
}
}
}
return Result.Ok(groundedResult);
}
catch (JsonException ex)
@@ -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);
+520 -292
View File
@@ -3,44 +3,103 @@
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Components.Atoms
@using System.Net.Http.Json
@inject HttpClient Http
@inject IKnowledgeService KnowledgeService
@inject AuthenticationStateProvider AuthStateProvider
<div class="intelligence-page">
<header class="intelligence-header">
<div class="header-title-section">
<h1>Global AI Q&A</h1>
<p class="subtitle">Search, interrogate, and extract grounded facts from your library using Polyglot KM-RAG</p>
<h1 class="neon-glow-text">Global Intelligence</h1>
<p class="subtitle">Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG</p>
</div>
</header>
<div class="intelligence-layout glass-panel">
<div class="search-scope-bar">
<div class="input-group search-input-group">
<input class="nexus-input"
placeholder="Ask a question about your books..."
@bind="_question"
@bind:event="oninput"
@onkeyup="HandleKeyUp" />
<button class="btn-nexus primary search-btn"
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
@onclick="AskQuestionAsync">
@if (_isLoading)
<div class="chat-thread-container">
@if (_chatMessages.Count == 0)
{
<div class="spinner-glow small btn-spinner"></div>
<div class="welcome-state">
<div class="welcome-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<h3>Start Interrogating Your Library</h3>
<p>Ask complex questions across your entire ebook collection. The KM-RAG engine dynamically builds semantic maps, resolves dependencies, and formulates high-fidelity, grounded answers with interactive popover citations.</p>
</div>
}
else
{
<span>Ask AI</span>
<div class="chat-bubbles-scroll">
@foreach (var message in _chatMessages)
{
<div class="message-row @(message.Sender == "User" ? "user-row" : "ai-row")" key="@message.Id">
<div class="message-avatar">
@if (message.Sender == "User")
{
<i class="bi bi-person-fill"></i>
}
else
{
<i class="bi bi-robot"></i>
}
</div>
<div class="message-bubble @(message.Sender == "User" ? "user-bubble" : "ai-bubble")">
<div class="message-header">
<span class="sender-name">@message.Sender</span>
<span class="message-time">@message.Timestamp.ToString("HH:mm")</span>
</div>
<div class="message-content">
@foreach (var segment in message.Segments)
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
</div>
</div>
}
@if (_isLoading)
{
<div class="message-row ai-row">
<div class="message-avatar">
<i class="bi bi-robot"></i>
</div>
<div class="message-bubble ai-bubble pending-bubble">
<div class="message-header">
<span class="sender-name">AI</span>
<span class="message-time">Thinking...</span>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<span class="loading-label">Analyzing conceptual graphs and synthesizing response...</span>
</div>
</div>
</div>
}
</div>
}
</button>
</div>
<div class="chat-input-controls">
<div class="input-panel-wrapper">
<div class="scope-bar">
<div class="scope-selector">
<label for="book-select">Scope:</label>
<label for="book-select"><i class="bi bi-compass"></i> Scope:</label>
<select id="book-select" class="nexus-select" @bind="_selectedBookId">
<option value="">All Books (Global Search)</option>
@if (_books != null)
@@ -54,133 +113,320 @@
</div>
</div>
<div class="results-area">
<div class="input-field-group">
<input class="nexus-input"
placeholder="Ask a question about your books..."
@bind="_question"
@bind:event="oninput"
@onkeyup="HandleKeyUp"
disabled="@_isLoading" />
<button class="btn-nexus primary search-btn"
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
@onclick="AskQuestionAsync">
@if (_isLoading)
{
<div class="loading-state">
<div class="nexus-spinner"></div>
<span>Analyzing conceptual graph and synthesizing response...</span>
</div>
}
else if (_response != null)
{
<div class="response-container">
<div class="response-section">
<h4><i class="bi bi-robot"></i> Answer</h4>
<div class="answer-text">
@_response.Answer
</div>
</div>
@if (_response.Citations != null && _response.Citations.Any())
{
<div class="citations-section">
<h4><i class="bi bi-journal-check"></i> Grounded Citations</h4>
<div class="citations-grid">
@foreach (var citation in _response.Citations)
{
<div class="citation-card">
<div class="citation-header">
<span class="source-badge">@citation.SourceBook</span>
@if (!string.IsNullOrEmpty(citation.CitationId) && citation.CitationId.Length > 8)
{
<span class="id-badge">ID: @citation.CitationId.Substring(0, Math.Min(8, citation.CitationId.Length))</span>
}
</div>
<div class="citation-body">
"@citation.Snippet"
</div>
</div>
}
</div>
</div>
}
</div>
}
else if (_hasSearched)
{
<div class="empty-state">
<i class="bi bi-info-circle"></i>
<p>No answers generated. Try adjusting your question.</p>
</div>
<div class="btn-spinner"></div>
}
else
{
<div class="welcome-state">
<div class="welcome-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<h3>Start Interrogating Your Library</h3>
<p>Ask complex questions across all your books. The system will search vectors, pull concept graph relations, and formulate a grounded answer with precise citations.</p>
</div>
<span><i class="bi bi-send-fill"></i></span>
}
</button>
</div>
</div>
</div>
</div>
</div>
<style>
.intelligence-page {
padding: 3rem 2rem;
padding: 2rem;
max-width: 1100px;
margin: 0 auto;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
animation: fadeIn 0.5s ease-out;
}
.intelligence-header {
margin-bottom: 2rem;
margin-bottom: 1.5rem;
flex-shrink: 0;
}
.header-title-section h1 {
font-family: var(--nexus-font-serif);
font-size: 2.8rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
background: linear-gradient(135deg, var(--nexus-text, #ffffff) 0%, rgba(255, 255, 255, 0.7) 100%);
.neon-glow-text {
font-family: var(--nexus-font-sans, 'Outfit', sans-serif);
font-size: 2.5rem;
font-weight: 800;
margin: 0 0 0.25rem 0;
background: linear-gradient(135deg, #00ff99 0%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 8px rgba(6, 182, 212, 0.2));
}
.subtitle {
font-size: 1rem;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
.intelligence-layout {
padding: 2.5rem;
border-radius: 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.search-scope-bar {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 2.5rem;
flex-wrap: wrap;
}
.search-input-group {
flex-grow: 1;
display: flex;
flex-direction: column;
border-radius: 20px;
background: rgba(10, 16, 26, 0.45);
border: 1px solid rgba(6, 182, 212, 0.15);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4), 0 0 20px rgba(6, 182, 212, 0.05);
overflow: hidden;
}
.chat-thread-container {
flex-grow: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
}
/* Custom Scrollbars */
.chat-thread-container::-webkit-scrollbar {
width: 6px;
}
.chat-thread-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.01);
}
.chat-thread-container::-webkit-scrollbar-thumb {
background: rgba(6, 182, 212, 0.2);
border-radius: 4px;
}
.chat-thread-container::-webkit-scrollbar-thumb:hover {
background: rgba(6, 182, 212, 0.4);
}
.chat-bubbles-scroll {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.message-row {
display: flex;
gap: 1rem;
width: 100%;
max-width: 85%;
animation: bubble-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.user-row {
align-self: flex-end;
flex-direction: row-reverse;
}
.ai-row {
align-self: flex-start;
}
.message-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.user-row .message-avatar {
background: linear-gradient(135deg, #7c3aed 0%, #4c1d95 100%);
color: #f5f3ff;
border: 1px solid rgba(139, 92, 246, 0.4);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.25);
}
.ai-row .message-avatar {
background: linear-gradient(135deg, #0f766e 0%, #115e59 100%);
color: #ccfbf1;
border: 1px solid rgba(13, 148, 136, 0.4);
box-shadow: 0 0 10px rgba(13, 148, 136, 0.25);
}
.message-bubble {
padding: 1.25rem 1.5rem;
border-radius: 16px;
position: relative;
line-height: 1.6;
font-size: 0.975rem;
}
.user-bubble {
background: rgba(43, 24, 80, 0.35);
border: 1px solid rgba(139, 92, 246, 0.25);
color: #f3e8ff;
border-top-right-radius: 4px;
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.05);
}
.ai-bubble {
background: rgba(10, 20, 30, 0.55);
border: 1px solid rgba(6, 182, 212, 0.2);
color: #e2e8f0;
border-top-left-radius: 4px;
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.05);
flex-grow: 1;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.75rem;
opacity: 0.6;
}
.sender-name {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-time {
font-family: monospace;
}
.message-content {
word-break: break-word;
}
/* Paragraph Spacing & Markdown */
.message-content p {
margin: 0 0 1rem 0;
}
.message-content p:last-child {
margin-bottom: 0;
}
.nexus-code-block {
background: rgba(0, 0, 0, 0.4) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
overflow-x: auto;
font-family: 'Fira Code', monospace;
font-size: 0.85rem;
color: #a7f3d0;
}
.nexus-inline-code {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-family: monospace;
font-size: 0.9em;
color: #f472b6; /* Light pink for inline code */
}
/* Pending State Bubble */
.pending-bubble {
border-color: rgba(6, 182, 212, 0.4);
box-shadow: 0 0 15px rgba(6, 182, 212, 0.1);
}
.typing-indicator {
display: flex;
gap: 4px;
align-items: center;
margin-bottom: 0.5rem;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #06b6d4;
border-radius: 50%;
display: inline-block;
animation: typing-bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
.loading-label {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
/* Input Controls */
.chat-input-controls {
padding: 1.5rem 2rem 2rem 2rem;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
.input-panel-wrapper {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.scope-bar {
display: flex;
align-items: center;
}
.scope-selector {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.nexus-select {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 0.25rem 0.25rem 0.25rem 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.08);
color: #ffffff;
padding: 0.35rem 2rem 0.35rem 0.75rem;
border-radius: 8px;
outline: none;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.3s ease;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 0.85em;
}
.nexus-select:focus {
border-color: #06b6d4;
box-shadow: 0 0 8px rgba(6, 182, 212, 0.2);
}
.input-field-group {
display: flex;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 0.35rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.search-input-group:focus-within {
border-color: var(--nexus-neon);
background: rgba(0, 255, 153, 0.02);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15);
.input-field-group:focus-within {
border-color: #06b6d4;
background: rgba(6, 182, 212, 0.01);
box-shadow: 0 0 15px rgba(6, 182, 212, 0.15);
}
.nexus-input {
@@ -189,249 +435,138 @@
border: none;
color: #ffffff;
font-size: 1rem;
font-family: var(--nexus-font-sans);
outline: none;
padding: 0.5rem 0;
padding: 0.5rem 1rem;
}
.nexus-input::placeholder {
color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.35);
}
.btn-nexus {
padding: 0.75rem 1.25rem;
border-radius: 10px;
font-size: 0.9rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
font-family: var(--nexus-font-sans);
}
.btn-nexus.primary {
background: var(--nexus-neon);
color: #000000;
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
color: #ffffff;
box-shadow: 0 4px 10px rgba(6, 182, 212, 0.25);
}
.btn-nexus:hover:not(:disabled) {
transform: translateY(-2px);
transform: translateY(-1px);
filter: brightness(1.1);
box-shadow: 0 4px 12px rgba(0, 255, 153, 0.3);
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.4);
}
.btn-nexus:disabled {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.25);
box-shadow: none;
cursor: not-allowed;
}
.search-btn {
padding: 0.5rem 1.5rem !important;
font-size: 0.95rem !important;
width: 46px;
height: 46px;
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
}
.scope-selector {
display: flex;
align-items: center;
gap: 0.75rem;
color: #A0A0A0;
font-family: var(--nexus-font-sans);
}
.nexus-select {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
color: #ffffff;
padding: 0.5rem 2.5rem 0.5rem 1rem;
border-radius: 10px;
outline: none;
cursor: pointer;
font-family: var(--nexus-font-sans);
font-size: 0.9rem;
transition: all 0.3s ease;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1em;
}
.nexus-select:focus {
border-color: var(--nexus-neon);
background-color: rgba(0, 255, 153, 0.02);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.15);
}
.results-area {
min-height: 250px;
display: flex;
flex-direction: column;
justify-content: center;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
color: rgba(255, 255, 255, 0.7);
}
.nexus-spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(0, 255, 153, 0.1);
border-radius: 50%;
border-top-color: var(--nexus-neon);
animation: spin 1s linear infinite;
filter: drop-shadow(0 0 8px var(--nexus-neon));
}
.welcome-state, .empty-state {
.welcome-state {
text-align: center;
color: rgba(255, 255, 255, 0.5);
padding: 3rem 1rem;
padding: 4rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.welcome-icon {
color: rgba(255, 255, 255, 0.25);
color: rgba(6, 182, 212, 0.4);
margin-bottom: 1.5rem;
animation: pulse 2s infinite alternate;
filter: drop-shadow(0 0 10px rgba(6, 182, 212, 0.2));
animation: pulse 2.5s infinite alternate;
}
.welcome-state h3 {
color: #ffffff;
font-family: var(--nexus-font-sans);
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
margin: 0 0 0.75rem 0;
}
.welcome-state p, .empty-state p {
max-width: 500px;
.welcome-state p {
max-width: 550px;
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
}
.response-container {
display: flex;
flex-direction: column;
gap: 2.5rem;
animation: slideUp 0.4s ease-out;
}
.response-section h4, .citations-section h4 {
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.5);
margin: 0 0 1rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.answer-text {
font-size: 1.15rem;
line-height: 1.7;
color: #ffffff;
background: rgba(255, 255, 255, 0.02);
padding: 1.5rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.citations-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
}
.citation-card {
background: rgba(255, 255, 255, 0.01);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.25rem;
transition: all 0.3s ease;
}
.citation-card:hover {
border-color: rgba(0, 255, 153, 0.3);
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.citation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.source-badge {
font-size: 0.8rem;
font-weight: 600;
color: var(--nexus-neon);
background: rgba(0, 255, 153, 0.05);
border: 1px solid rgba(0, 255, 153, 0.2);
padding: 0.25rem 0.75rem;
border-radius: 20px;
}
.id-badge {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-family: monospace;
}
.citation-body {
font-size: 0.95rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
font-style: italic;
}
.btn-spinner {
margin: 0;
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: #ffffff;
animation: spin 0.8s linear infinite;
}
/* Animations */
@@keyframes fadeIn {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
/* Keyframe Animations */
@@keyframes bubble-fade-in {
0% { opacity: 0; transform: translateY(10px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
@@keyframes typing-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@@keyframes pulse {
0% { transform: scale(0.96); opacity: 0.8; }
100% { transform: scale(1.04); opacity: 1; }
}
@@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@@keyframes pulse {
0% { transform: scale(0.95); opacity: 0.7; }
100% { transform: scale(1.05); opacity: 1; }
}
</style>
@code {
private string _question = string.Empty;
private string _selectedBookId = string.Empty;
private bool _isLoading;
private bool _hasSearched;
private GroundedResponseDto? _response;
private List<LastReadBookDto>? _books;
private List<ChatMessage> _chatMessages = new();
public class ChatMessage
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Sender { get; set; } = string.Empty; // "User" or "AI"
public string Text { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public List<ResponseSegment> Segments { get; set; } = new();
public List<CitationDto> Citations { get; set; } = new();
}
public class ResponseSegment
{
public string Text { get; set; } = string.Empty;
public bool IsCitation { get; set; }
public string CitationId { get; set; } = string.Empty;
}
protected override async Task OnInitializedAsync()
{
@@ -457,9 +592,18 @@
{
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
var userQuestion = _question;
_question = string.Empty; // Clear input field immediately
_isLoading = true;
_hasSearched = true;
_response = null;
// Add user query message
_chatMessages.Add(new ChatMessage
{
Sender = "User",
Text = userQuestion,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = userQuestion, IsCitation = false } }
});
StateHasChanged();
try
@@ -473,27 +617,38 @@
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
var result = await KnowledgeService.AskQuestionAsync(_question, tenantId, ebookId);
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
if (result.IsSuccess)
{
_response = result.Value;
var response = result.Value;
_chatMessages.Add(new ChatMessage
{
Sender = "AI",
Text = response.Answer,
Segments = ParseSegments(response.Answer),
Citations = response.Citations
});
}
else
{
_response = new GroundedResponseDto
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
_chatMessages.Add(new ChatMessage
{
Answer = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}",
Citations = new List<CitationDto>()
};
Sender = "AI",
Text = errMsg,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
});
}
}
catch (Exception ex)
{
_response = new GroundedResponseDto
var errMsg = $"Network/API Error: {ex.Message}";
_chatMessages.Add(new ChatMessage
{
Answer = $"Network/API Error: {ex.Message}",
Citations = new List<CitationDto>()
};
Sender = "AI",
Text = errMsg,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
});
}
finally
{
@@ -501,4 +656,77 @@
StateHasChanged();
}
}
private List<ResponseSegment> ParseSegments(string text)
{
var segments = new List<ResponseSegment>();
if (string.IsNullOrEmpty(text)) return segments;
// Matches [Source ID: some-id] OR raw GUIDs in brackets [e225e58f-7539-cd51-e0ab-82741ec7e65c]
var regex = new System.Text.RegularExpressions.Regex(
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var matches = regex.Matches(text);
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in matches)
{
if (match.Index > lastIndex)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex, match.Index - lastIndex),
IsCitation = false
});
}
var citationId = match.Groups[1].Success
? match.Groups[1].Value.Trim()
: match.Groups[2].Value.Trim();
segments.Add(new ResponseSegment
{
IsCitation = true,
CitationId = citationId
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex),
IsCitation = false
});
}
return segments;
}
private MarkupString RenderMarkdown(string text)
{
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
// 1. HTML Encode to prevent XSS
var html = System.Net.WebUtility.HtmlEncode(text);
// 2. Bold: **text** -> <strong>text</strong>
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
// 3. Italic: *text* -> <em>text</em>
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
// 4. Code blocks: ```language ... ``` -> <pre class="nexus-code-block"><code>...</code></pre>
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
// 5. Inline Code: `code` -> <code class="nexus-inline-code">code</code>
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
// 6. Newlines: \n -> <br />
html = html.Replace("\n", "<br />");
return new MarkupString(html);
}
}
@@ -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 */ }