feat: implement interactive citation markers with metadata and optimize knowledge caching with concurrent collision handling
This commit is contained in:
@@ -13,4 +13,6 @@ public class CitationDto
|
|||||||
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID
|
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 Snippet { get; set; } = string.Empty; // Verified text snippet from context
|
||||||
public string SourceBook { get; set; } = string.Empty; // Book title or description
|
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
|
// 1. Check Cache
|
||||||
var cached = await dbContext.SemanticKnowledgeCache
|
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)
|
if (cached != null && cached.PromptVersion == PromptVersion)
|
||||||
{
|
{
|
||||||
@@ -112,7 +112,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate concurrent active requests for the exact same hash
|
// Deduplicate concurrent active requests for the exact same hash
|
||||||
var requestKey = $"{tenantId}:{hash}:{traceType}";
|
var requestKey = $"{hash}:{traceType}";
|
||||||
|
|
||||||
var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
|
var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
|
||||||
new Lazy<Task<Result<KnowledgePacket>>>(
|
new Lazy<Task<Result<KnowledgePacket>>>(
|
||||||
@@ -184,7 +184,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
// 4. Save to Cache
|
// 4. Save to Cache
|
||||||
var cached = await dbContext.SemanticKnowledgeCache
|
var cached = await dbContext.SemanticKnowledgeCache
|
||||||
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId);
|
.FirstOrDefaultAsync(c => c.ContentHash == hash);
|
||||||
|
|
||||||
var cacheEntry = new SemanticKnowledgeCache
|
var cacheEntry = new SemanticKnowledgeCache
|
||||||
{
|
{
|
||||||
@@ -208,7 +208,14 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
// 5. Process structured KnowledgeUnits (Graph Expansion)
|
// 5. Process structured KnowledgeUnits (Graph Expansion)
|
||||||
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
|
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await dbContext.SaveChangesAsync();
|
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);
|
return Result.Ok(knowledgePacket);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
@@ -1055,18 +1062,55 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
return Result.Fail("Failed to deserialize grounded RAG response.");
|
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)
|
foreach (var citation in groundedResult.Citations)
|
||||||
{
|
{
|
||||||
if (pointMap.TryGetValue(citation.CitationId, out var point) &&
|
if (pointMap.TryGetValue(citation.CitationId, out var point) &&
|
||||||
point.Payload.TryGetValue("ebookId", out var ev) &&
|
point.Payload.TryGetValue("ebookId", out var ev) &&
|
||||||
Guid.TryParse(ev.StringValue, out var ebId) &&
|
Guid.TryParse(ev.StringValue, out var ebId))
|
||||||
ebookTitles.TryGetValue(ebId, out var title))
|
{
|
||||||
|
if (ebookTitles.TryGetValue(ebId, out var title))
|
||||||
{
|
{
|
||||||
citation.SourceBook = 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);
|
return Result.Ok(groundedResult);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
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 bool _isJsInitialized;
|
||||||
private ElementReference _containerRef;
|
private ElementReference _containerRef;
|
||||||
private bool _isInteractive;
|
private bool _isInteractive;
|
||||||
|
private string? _currentActiveBlockId;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -143,6 +144,7 @@
|
|||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public async Task HandleBlockReached(string blockId, string content)
|
public async Task HandleBlockReached(string blockId, string content)
|
||||||
{
|
{
|
||||||
|
_currentActiveBlockId = blockId;
|
||||||
await Coordinator.OnBlockReachedAsync(blockId, content);
|
await Coordinator.OnBlockReachedAsync(blockId, content);
|
||||||
|
|
||||||
if (ViewModel != null)
|
if (ViewModel != null)
|
||||||
@@ -160,8 +162,15 @@
|
|||||||
|
|
||||||
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
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);
|
Logger.LogInformation("[Sync] Received progress from another device: block {BlockId} at {Timestamp}", blockId, timestamp);
|
||||||
|
|
||||||
|
_currentActiveBlockId = blockId;
|
||||||
await ScrollToNodeAsync(blockId);
|
await ScrollToNodeAsync(blockId);
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
@@ -212,6 +221,7 @@
|
|||||||
private async Task LoadChapterAsync(int index)
|
private async Task LoadChapterAsync(int index)
|
||||||
{
|
{
|
||||||
await Coordinator.ClearAsync();
|
await Coordinator.ClearAsync();
|
||||||
|
_isJsInitialized = false; // Reset JS initialization to re-bind the scroll observer to new DOM elements!
|
||||||
_isLoadingChapter = true;
|
_isLoadingChapter = true;
|
||||||
StatusMessage = "Wczytywanie treści...";
|
StatusMessage = "Wczytywanie treści...";
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
@@ -253,6 +263,7 @@
|
|||||||
{
|
{
|
||||||
var targetBlockId = NavigationService.PendingScrollBlockId;
|
var targetBlockId = NavigationService.PendingScrollBlockId;
|
||||||
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
|
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
|
||||||
|
_currentActiveBlockId = targetBlockId;
|
||||||
|
|
||||||
// Give the browser slightly more than one frame to render the loaded blocks
|
// Give the browser slightly more than one frame to render the loaded blocks
|
||||||
await Task.Delay(150);
|
await Task.Delay(150);
|
||||||
|
|||||||
@@ -3,44 +3,103 @@
|
|||||||
@using NexusReader.Application.DTOs.AI
|
@using NexusReader.Application.DTOs.AI
|
||||||
@using NexusReader.Application.Abstractions.Services
|
@using NexusReader.Application.Abstractions.Services
|
||||||
@using NexusReader.Application.DTOs.User
|
@using NexusReader.Application.DTOs.User
|
||||||
|
@using NexusReader.UI.Shared.Components.Atoms
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
@inject IKnowledgeService KnowledgeService
|
@inject IKnowledgeService KnowledgeService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
|
|
||||||
<div class="intelligence-page">
|
<div class="intelligence-page">
|
||||||
<header class="intelligence-header">
|
<header class="intelligence-header">
|
||||||
<div class="header-title-section">
|
<div class="header-title-section">
|
||||||
<h1>Global AI Q&A</h1>
|
<h1 class="neon-glow-text">Global Intelligence</h1>
|
||||||
<p class="subtitle">Search, interrogate, and extract grounded facts from your library using Polyglot KM-RAG</p>
|
<p class="subtitle">Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="intelligence-layout glass-panel">
|
<div class="intelligence-layout glass-panel">
|
||||||
<div class="search-scope-bar">
|
<div class="chat-thread-container">
|
||||||
<div class="input-group search-input-group">
|
@if (_chatMessages.Count == 0)
|
||||||
<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="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
|
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>
|
||||||
|
|
||||||
|
<div class="chat-input-controls">
|
||||||
|
<div class="input-panel-wrapper">
|
||||||
|
<div class="scope-bar">
|
||||||
<div class="scope-selector">
|
<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">
|
<select id="book-select" class="nexus-select" @bind="_selectedBookId">
|
||||||
<option value="">All Books (Global Search)</option>
|
<option value="">All Books (Global Search)</option>
|
||||||
@if (_books != null)
|
@if (_books != null)
|
||||||
@@ -54,133 +113,320 @@
|
|||||||
</div>
|
</div>
|
||||||
</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)
|
@if (_isLoading)
|
||||||
{
|
{
|
||||||
<div class="loading-state">
|
<div class="btn-spinner"></div>
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="welcome-state">
|
<span><i class="bi bi-send-fill"></i></span>
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.intelligence-page {
|
.intelligence-page {
|
||||||
padding: 3rem 2rem;
|
padding: 2rem;
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
height: calc(100vh - 100px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
animation: fadeIn 0.5s ease-out;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intelligence-header {
|
.intelligence-header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title-section h1 {
|
.neon-glow-text {
|
||||||
font-family: var(--nexus-font-serif);
|
font-family: var(--nexus-font-sans, 'Outfit', sans-serif);
|
||||||
font-size: 2.8rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
background: linear-gradient(135deg, var(--nexus-text, #ffffff) 0%, rgba(255, 255, 255, 0.7) 100%);
|
background: linear-gradient(135deg, #00ff99 0%, #06b6d4 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(6, 182, 212, 0.2));
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intelligence-layout {
|
.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;
|
flex-grow: 1;
|
||||||
display: flex;
|
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);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 10px;
|
color: #ffffff;
|
||||||
padding: 0.25rem 0.25rem 0.25rem 1.25rem;
|
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);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input-group:focus-within {
|
.input-field-group:focus-within {
|
||||||
border-color: var(--nexus-neon);
|
border-color: #06b6d4;
|
||||||
background: rgba(0, 255, 153, 0.02);
|
background: rgba(6, 182, 212, 0.01);
|
||||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15);
|
box-shadow: 0 0 15px rgba(6, 182, 212, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nexus-input {
|
.nexus-input {
|
||||||
@@ -189,249 +435,138 @@
|
|||||||
border: none;
|
border: none;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: var(--nexus-font-sans);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nexus-input::placeholder {
|
.nexus-input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-nexus {
|
.btn-nexus {
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
border: none;
|
border: none;
|
||||||
font-family: var(--nexus-font-sans);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-nexus.primary {
|
.btn-nexus.primary {
|
||||||
background: var(--nexus-neon);
|
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||||
color: #000000;
|
color: #ffffff;
|
||||||
|
box-shadow: 0 4px 10px rgba(6, 182, 212, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-nexus:hover:not(:disabled) {
|
.btn-nexus:hover:not(:disabled) {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-1px);
|
||||||
filter: brightness(1.1);
|
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 {
|
.btn-nexus:disabled {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
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;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn {
|
.search-btn {
|
||||||
padding: 0.5rem 1.5rem !important;
|
width: 46px;
|
||||||
font-size: 0.95rem !important;
|
height: 46px;
|
||||||
|
padding: 0 !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
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 {
|
.welcome-state {
|
||||||
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 {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
padding: 3rem 1rem;
|
padding: 4rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-icon {
|
.welcome-icon {
|
||||||
color: rgba(255, 255, 255, 0.25);
|
color: rgba(6, 182, 212, 0.4);
|
||||||
margin-bottom: 1.5rem;
|
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 {
|
.welcome-state h3 {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: var(--nexus-font-sans);
|
font-size: 1.5rem;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-state p, .empty-state p {
|
.welcome-state p {
|
||||||
max-width: 500px;
|
max-width: 550px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.6;
|
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 {
|
.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 */
|
/* Keyframe Animations */
|
||||||
@@keyframes fadeIn {
|
@@keyframes bubble-fade-in {
|
||||||
from { opacity: 0; transform: translateY(15px); }
|
0% { opacity: 0; transform: translateY(10px) scale(0.98); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@keyframes slideUp {
|
@@keyframes typing-bounce {
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
0%, 100% { transform: translateY(0); }
|
||||||
to { opacity: 1; 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 {
|
@@keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@keyframes pulse {
|
|
||||||
0% { transform: scale(0.95); opacity: 0.7; }
|
|
||||||
100% { transform: scale(1.05); opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string _question = string.Empty;
|
private string _question = string.Empty;
|
||||||
private string _selectedBookId = string.Empty;
|
private string _selectedBookId = string.Empty;
|
||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
private bool _hasSearched;
|
|
||||||
private GroundedResponseDto? _response;
|
|
||||||
private List<LastReadBookDto>? _books;
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -457,9 +592,18 @@
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
|
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
|
||||||
|
|
||||||
|
var userQuestion = _question;
|
||||||
|
_question = string.Empty; // Clear input field immediately
|
||||||
_isLoading = true;
|
_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();
|
StateHasChanged();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -473,27 +617,38 @@
|
|||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
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)
|
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
|
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."}",
|
Sender = "AI",
|
||||||
Citations = new List<CitationDto>()
|
Text = errMsg,
|
||||||
};
|
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_response = new GroundedResponseDto
|
var errMsg = $"Network/API Error: {ex.Message}";
|
||||||
|
_chatMessages.Add(new ChatMessage
|
||||||
{
|
{
|
||||||
Answer = $"Network/API Error: {ex.Message}",
|
Sender = "AI",
|
||||||
Citations = new List<CitationDto>()
|
Text = errMsg,
|
||||||
};
|
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -501,4 +656,77 @@
|
|||||||
StateHasChanged();
|
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) =>
|
_hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) =>
|
||||||
{
|
{
|
||||||
// Note: In the future we might want to receive ebookId and progress here too
|
// 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);
|
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,6 +83,8 @@ public class SyncService : ISyncService, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (pageId == _lastSentPageId) return Result.Ok();
|
if (pageId == _lastSentPageId) return Result.Ok();
|
||||||
|
|
||||||
|
_lastSentPageId = pageId;
|
||||||
|
|
||||||
// Proper trailing-edge debounce
|
// Proper trailing-edge debounce
|
||||||
_debounceCts?.Cancel();
|
_debounceCts?.Cancel();
|
||||||
_debounceCts = new CancellationTokenSource();
|
_debounceCts = new CancellationTokenSource();
|
||||||
@@ -93,7 +101,6 @@ public class SyncService : ISyncService, IAsyncDisposable
|
|||||||
if (_hubConnection?.State == HubConnectionState.Connected)
|
if (_hubConnection?.State == HubConnectionState.Connected)
|
||||||
{
|
{
|
||||||
await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, chapterIndex);
|
await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, chapterIndex);
|
||||||
_lastSentPageId = pageId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
|
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
|
||||||
|
|||||||
Reference in New Issue
Block a user