feat: integrate AI-driven selection panel with context-aware text summarization and quiz generation features.

This commit is contained in:
2026-04-26 20:36:08 +02:00
parent 82d726097f
commit 39a9ca5706
25 changed files with 819 additions and 219 deletions
@@ -6,5 +6,7 @@ namespace NexusReader.Application.Abstractions.Services;
public interface IKnowledgeService
{
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default);
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
}
@@ -18,4 +18,5 @@ public record KnowledgePacket
[JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new();
[JsonPropertyName("quizzes")] public List<QuizQuestion> Quizzes { get; init; } = new();
[JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; }
[JsonPropertyName("summary")] public string? Summary { get; init; }
}
@@ -0,0 +1,69 @@
using System.Text;
using System.Text.RegularExpressions;
namespace NexusReader.Infrastructure.Helpers;
public static class JsonRepairHelper
{
public static string Repair(string json)
{
if (string.IsNullOrWhiteSpace(json)) return json;
json = json.Trim();
// 1. If it doesn't end with } or ], it's definitely truncated
if (!json.EndsWith("}") && !json.EndsWith("]"))
{
// Try to find the last "clean" closing point before the truncation
// We look for a comma, a closing brace, or a closing bracket that is followed by noise
int lastGoodComma = json.LastIndexOf(',');
int lastGoodBrace = json.LastIndexOf('}');
int lastGoodBracket = json.LastIndexOf(']');
int cutoff = Math.Max(lastGoodComma, Math.Max(lastGoodBrace, lastGoodBracket));
if (cutoff > 0)
{
// Prune the "garbage" at the end
json = json.Substring(0, cutoff);
}
// Now apply the standard stack-based closing logic
var stack = new Stack<char>();
bool inString = false;
bool escaped = false;
foreach (char c in json)
{
if (escaped) { escaped = false; continue; }
if (c == '\\') { escaped = true; continue; }
if (c == '"') { inString = !inString; continue; }
if (inString) continue;
if (c == '{' || c == '[') stack.Push(c);
else if (c == '}' || c == ']')
{
if (stack.Count > 0)
{
var last = stack.Peek();
if ((c == '}' && last == '{') || (c == ']' && last == '['))
stack.Pop();
}
}
}
var builder = new StringBuilder(json);
if (inString) builder.Append('"');
while (stack.Count > 0)
{
var c = stack.Pop();
if (c == '{') builder.Append("}");
else if (c == '[') builder.Append("]");
}
return builder.ToString();
}
return json;
}
}
@@ -35,29 +35,37 @@ public class KnowledgeService : IKnowledgeService
}
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default)
{
return await GetKnowledgeInternalAsync(text, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken);
}
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default)
{
return await GetKnowledgeInternalAsync(text, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken);
}
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default)
{
return await GetKnowledgeInternalAsync(text, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken);
}
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(text))
{
return Result.Fail("Input text is empty.");
}
Console.WriteLine($"[KnowledgeService] Starting extraction for text: {text.Substring(0, Math.Min(text.Length, 50))}...");
Console.WriteLine($"[KnowledgeService] Starting extraction ({cacheSuffix}) for text sample: {text.Substring(0, Math.Min(text.Length, 50))}...");
// Normalize text to ensure consistent hashing and reduce token noise
var normalizedText = ContentHasher.Normalize(text);
// Phase 4: Request Pre-processing (Token Saving)
if (normalizedText.Length > _settings.MaxInputLength)
{
Console.WriteLine($"[KnowledgeService] Error: Input too long ({normalizedText.Length} > {_settings.MaxInputLength})");
return Result.Fail($"Input text is too long ({normalizedText.Length} characters after normalization). Max allowed is {_settings.MaxInputLength}.");
normalizedText = normalizedText.Substring(0, _settings.MaxInputLength);
Console.WriteLine($"[KnowledgeService] WARNING: Input text truncated to {_settings.MaxInputLength} chars.");
}
// Simple token estimation (4 chars per token)
var estimatedTokens = normalizedText.Length / 4;
Console.WriteLine($"[KnowledgeService] Processing request with ~{estimatedTokens} tokens.");
var hash = ContentHasher.ComputeHash(normalizedText);
var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix;
// 1. Check Cache
var cached = await _dbContext.SemanticKnowledgeCache
@@ -65,28 +73,19 @@ public class KnowledgeService : IKnowledgeService
if (cached != null)
{
Console.WriteLine($"[KnowledgeService] Cache hit for hash: {hash}");
try
{
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData);
if (packet != null)
{
return Result.Ok(packet);
}
}
catch (JsonException ex)
{
Console.WriteLine($"[KnowledgeService] Cache deserialization error: {ex.Message}");
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (packet != null) return Result.Ok(packet);
}
catch { }
}
// 2. Call AI Client
try
{
Console.WriteLine($"[KnowledgeService] Calling Gemini AI with Model: {_settings.Model}...");
var options = new ChatOptions
{
// ResponseFormat = ChatResponseFormat.Json, // Disabled due to GeminiMappingException in current library version
Temperature = (float)_settings.Temperature,
MaxOutputTokens = _settings.MaxOutputTokens
};
@@ -94,61 +93,46 @@ public class KnowledgeService : IKnowledgeService
var response = await _retryPipeline.ExecuteAsync(async ct =>
await _chatClient.GetResponseAsync(new List<ChatMessage>
{
new ChatMessage(ChatRole.System, PromptRegistry.KnowledgeExtractionSystemPrompt),
new ChatMessage(ChatRole.System, systemPrompt),
new ChatMessage(ChatRole.User, normalizedText)
}, options, cancellationToken: ct), cancellationToken);
var jsonResponse = response.Text;
if (string.IsNullOrWhiteSpace(jsonResponse))
var rawResponse = response.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(rawResponse)) return Result.Fail("AI returned an empty response.");
// Cleanup markdown code blocks and repair truncation
var jsonResponse = rawResponse.Replace("```json", "").Replace("```", "").Trim();
jsonResponse = JsonRepairHelper.Repair(jsonResponse);
try
{
Console.WriteLine("[KnowledgeService] AI returned empty response.");
return Result.Fail("AI returned an empty response.");
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response.");
// 3. Save to Cache
var cacheEntry = new SemanticKnowledgeCache
{
ContentHash = hash,
JsonData = jsonResponse,
ModelId = _settings.Model,
PromptVersion = PromptVersion,
CreatedAt = DateTime.UtcNow
};
if (cached == null) _dbContext.SemanticKnowledgeCache.Add(cacheEntry);
else { cached.JsonData = jsonResponse; cached.CreatedAt = DateTime.UtcNow; }
await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok(knowledgePacket);
}
Console.WriteLine($"[KnowledgeService] AI Response received ({jsonResponse.Length} chars).");
// Cleanup potential markdown if Gemini still adds it despite options
jsonResponse = jsonResponse.Replace("```json", "").Replace("```", "").Trim();
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse);
if (knowledgePacket == null)
catch (JsonException ex)
{
Console.WriteLine("[KnowledgeService] Failed to deserialize JSON response.");
return Result.Fail("Failed to deserialize AI response.");
Console.WriteLine($"[KnowledgeService] JSON Error: {ex.Message}. Raw length: {rawResponse.Length}");
return Result.Fail($"Failed to deserialize AI response: {ex.Message}");
}
// 3. Save to Cache
Console.WriteLine("[KnowledgeService] Saving result to cache...");
var cacheEntry = new SemanticKnowledgeCache
{
ContentHash = hash,
JsonData = jsonResponse,
ModelId = _settings.Model,
PromptVersion = PromptVersion,
CreatedAt = DateTime.UtcNow
};
if (cached == null)
{
_dbContext.SemanticKnowledgeCache.Add(cacheEntry);
}
else
{
cached.JsonData = jsonResponse;
cached.CreatedAt = DateTime.UtcNow;
}
await _dbContext.SaveChangesAsync(cancellationToken);
Console.WriteLine("[KnowledgeService] Extraction successful.");
return Result.Ok(knowledgePacket);
}
catch (Exception ex)
{
Console.WriteLine($"[KnowledgeService] CRITICAL ERROR: {ex.GetType().Name}: {ex.Message}");
if (ex.InnerException != null)
Console.WriteLine($"[KnowledgeService] Inner Error: {ex.InnerException.Message}");
return Result.Fail(new Error("Failed to extract knowledge from AI").CausedBy(ex));
}
}
@@ -160,12 +144,10 @@ public class KnowledgeService : IKnowledgeService
Console.WriteLine("[KnowledgeService] Clearing SemanticKnowledgeCache...");
_dbContext.SemanticKnowledgeCache.RemoveRange(_dbContext.SemanticKnowledgeCache);
await _dbContext.SaveChangesAsync(cancellationToken);
Console.WriteLine("[KnowledgeService] Cache cleared successfully.");
return Result.Ok();
}
catch (Exception ex)
{
Console.WriteLine($"[KnowledgeService] Error clearing cache: {ex.Message}");
return Result.Fail($"Failed to clear cache: {ex.Message}");
}
}
@@ -10,4 +10,15 @@ public static class PromptRegistry
"\"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ], " +
"\"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } " +
"}.";
public const string GraphExtractionPrompt =
"You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " +
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. You MUST use these exact IDs as the 'id' for the nodes representing those blocks. " +
"Include a 'current' node representing the block content itself if applicable. " +
"CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " +
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }";
public const string SummaryAndQuizPrompt =
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
}
@@ -0,0 +1,96 @@
@using NexusReader.UI.Shared.Services
@using NexusReader.Application.DTOs.AI
@inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService
@if (IsVisible)
{
<div class="selection-ai-panel expanded @(PositionBelow ? "below" : "")" style="@PanelStyle">
<div class="ai-bubble">
<div class="ai-avatar">
<div class="avatar-ring"></div>
<NexusIcon Name="robot" Size="48" Class="neon-pulse" />
<div class="avatar-label">
<span class="name">E-Czytnik</span>
<span class="role">Asystent AI</span>
</div>
</div>
<div class="ai-content">
@if (IsLoading)
{
<div class="loading-state">
<div class="shimmer">Skanowanie fragmentu...</div>
</div>
}
else if (Packet != null)
{
<div class="summary-box">
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Packet.Summary</NexusTypography>
</div>
<div class="ai-actions">
<button class="action-btn neon-border" @onclick="GenerateFullQuiz">Generuj Quiz dla całej strony</button>
<button class="action-btn ghost" @onclick="Close">Zamknij</button>
</div>
}
else
{
<div class="summary-box">
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału?</NexusTypography>
</div>
<div class="ai-actions">
<button class="action-btn neon-border" @onclick="RequestSummary">Podsumuj zaznaczenie</button>
<button class="action-btn ghost" @onclick="Close">Pomiń</button>
</div>
}
</div>
<div class="bubble-pointer"></div>
</div>
</div>
}
@code {
[Parameter] public string SelectedText { get; set; } = string.Empty;
[Parameter] public string BlockId { get; set; } = string.Empty;
[Parameter] public SelectionCoordinates? Coordinates { get; set; }
[Parameter] public string FullPageContent { get; set; } = string.Empty;
private bool IsVisible => !string.IsNullOrEmpty(SelectedText) && Coordinates != null;
private bool IsLoading = false;
private KnowledgePacket? Packet;
private bool PositionBelow => Coordinates != null && Coordinates.Top < 320;
protected override void OnParametersSet()
{
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}, PositionBelow: {PositionBelow}");
// Reset packet when selection changes
Packet = null;
}
private string PanelStyle => Coordinates != null
? string.Create(System.Globalization.CultureInfo.InvariantCulture,
$"top: {(PositionBelow ? Coordinates.Top + 35 : Coordinates.Top - 15):F1}px !important; " +
$"left: {Math.Clamp(Coordinates.Left + Coordinates.Width / 2, 280, 1600):F1}px !important; " +
$"transform: translate(-50%, {(PositionBelow ? "0" : "-100%")}) !important;")
: "";
private async Task RequestSummary()
{
IsLoading = true;
Packet = await Coordinator.RequestSummaryAndQuizAsync(SelectedText);
IsLoading = false;
}
private async Task GenerateFullQuiz()
{
IsLoading = true;
await Coordinator.RequestSummaryAndQuizAsync(FullPageContent);
IsLoading = false;
Close();
}
private void Close()
{
Packet = null;
InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
}
}
@@ -0,0 +1,158 @@
.selection-ai-panel {
position: fixed;
z-index: 9999;
width: 550px;
max-width: 90vw;
animation: fadeInScale 0.2s ease-out;
pointer-events: auto;
}
@keyframes fadeInScale {
from { opacity: 0; transform: translate(-50%, -90%) scale(0.95); }
to { opacity: 1; transform: translate(-50%, -100%) scale(1); }
}
.ai-bubble {
position: relative;
display: flex;
flex-direction: row;
gap: 1.5rem;
padding: 1.5rem;
background: rgba(18, 18, 18, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
color: #fff;
}
.ai-avatar {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 100px;
}
.avatar-label {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.avatar-label .name {
font-size: 0.8rem;
font-weight: 600;
color: #fff;
}
.avatar-label .role {
font-size: 0.7rem;
opacity: 0.6;
}
.neon-pulse {
color: #00ff99;
filter: drop-shadow(0 0 8px #00ff99);
animation: pulse 2s infinite ease-in-out;
}
@keyframes pulse {
0% { transform: scale(1); filter: drop-shadow(0 0 8px #00ff99); }
50% { transform: scale(1.05); filter: drop-shadow(0 0 15px #00ff99); }
100% { transform: scale(1); filter: drop-shadow(0 0 8px #00ff99); }
}
.ai-content {
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: center;
}
.summary-box {
font-size: 0.95rem;
line-height: 1.5;
color: #e0e0e0;
max-height: 40vh;
overflow-y: auto;
padding-right: 8px;
}
.summary-box::-webkit-scrollbar {
width: 4px;
}
.summary-box::-webkit-scrollbar-thumb {
background: rgba(0, 255, 153, 0.3);
border-radius: 2px;
}
.ai-actions {
display: flex;
gap: 1rem;
}
.action-btn {
padding: 0.5rem 1.2rem;
border-radius: 20px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.action-btn.ghost {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #aaa;
}
.action-btn.neon-border {
background: rgba(0, 255, 153, 0.1);
border: 1px solid #00ff99;
color: #00ff99;
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 255, 153, 0.2);
}
.bubble-pointer {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
}
.selection-ai-panel:not(.below) .bubble-pointer {
bottom: -10px;
border-top: 10px solid rgba(18, 18, 18, 0.95);
}
.selection-ai-panel.below .bubble-pointer {
top: -10px;
border-bottom: 10px solid rgba(18, 18, 18, 0.95);
}
.loading-state {
padding: 1rem;
}
.shimmer {
background: linear-gradient(90deg, transparent, rgba(0, 255, 153, 0.2), transparent);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
padding: 0.5rem;
border-radius: 4px;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@@ -7,13 +7,17 @@
@inject IJSRuntime JS
@inject IFocusModeService FocusMode
@inject IKnowledgeGraphService GraphService
@inject IReaderInteractionService InteractionService
<div class="knowledge-graph-container" id="@ContainerId">
@if (GraphData == null)
@if (GraphService.IsLoading || GraphService.CurrentGraphData == null)
{
<div class="loading-state">
<NexusIcon Name="robot" Size="48" Class="neon-glow" />
<NexusTypography>Analyzing Chapter Nodes...</NexusTypography>
<div class="preloader-robot">
<NexusIcon Name="robot" Size="64" Class="neon-pulse" />
<div class="scan-line"></div>
</div>
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">Mapowanie relacji rozdziału...</NexusTypography>
</div>
}
else
@@ -31,7 +35,6 @@
[Parameter] public EventCallback<string> OnNodeSelected { get; set; }
private string ContainerId = "d3-graph-container";
private GraphDataDto? GraphData;
private IJSObjectReference? _module;
private DotNetObjectReference<KnowledgeGraph>? _dotNetHelper;
@@ -40,12 +43,22 @@
FocusMode.OnFocusModeChanged += HandleFocusSimulation;
GraphService.OnGraphUpdated += HandleGraphUpdate;
GraphService.OnActiveNodeChanged += HandleActiveNodeChange;
GraphService.OnLoadingChanged += HandleLoadingChange;
}
private async void HandleGraphUpdate()
{
if (_module == null) return;
await _module.InvokeVoidAsync("updateData", GraphService.CurrentGraphData);
if (GraphService.CurrentGraphData == null)
{
await _module.InvokeVoidAsync("clear");
}
else
{
await _module.InvokeVoidAsync("updateData", GraphService.CurrentGraphData);
}
await InvokeAsync(StateHasChanged);
}
@@ -55,16 +68,20 @@
await _module.InvokeVoidAsync("setActiveNode", nodeId);
}
private async void HandleLoadingChange(bool isLoading)
{
await InvokeAsync(StateHasChanged);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var result = await Mediator.Send(new GetKnowledgeGraphQuery());
if (result.IsSuccess)
await InitializeGraphAsync();
if (GraphService.CurrentGraphData != null)
{
GraphData = result.Value;
StateHasChanged();
await InitializeGraphAsync();
HandleGraphUpdate();
}
}
}
@@ -73,7 +90,7 @@
{
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/knowledgeGraph.js");
_dotNetHelper = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("mount", ContainerId, GraphData, _dotNetHelper);
await _module.InvokeVoidAsync("mount", ContainerId, GraphService.CurrentGraphData, _dotNetHelper);
}
private async Task ZoomIn() => await (_module?.InvokeVoidAsync("zoomIn") ?? ValueTask.CompletedTask);
@@ -83,6 +100,8 @@
[JSInvokable]
public async Task OnNodeClicked(string nodeId)
{
InteractionService.NotifyNodeSelected(nodeId);
if (OnNodeSelected.HasDelegate)
{
await OnNodeSelected.InvokeAsync(nodeId);
@@ -106,6 +125,10 @@
public async ValueTask DisposeAsync()
{
FocusMode.OnFocusModeChanged -= HandleFocusSimulation;
GraphService.OnGraphUpdated -= HandleGraphUpdate;
GraphService.OnActiveNodeChanged -= HandleActiveNodeChange;
GraphService.OnLoadingChanged -= HandleLoadingChange;
try
{
if (_module is not null)
@@ -114,14 +137,7 @@
await _module.DisposeAsync();
}
}
catch (JSDisconnectedException)
{
// Ignored, the circuit is already closed
}
catch (TaskCanceledException)
{
// Ignored, the circuit is already closed
}
catch { }
_dotNetHelper?.Dispose();
}
@@ -46,19 +46,50 @@
font-size: 0.8rem;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
animation: pulse 2s infinite ease-in-out;
gap: 1.5rem;
color: #fff;
text-align: center;
}
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
.preloader-robot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.neon-pulse {
color: var(--nexus-neon);
filter: drop-shadow(0 0 10px var(--nexus-neon));
animation: robot-pulse 2s infinite ease-in-out;
}
@keyframes robot-pulse {
0% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
50% { transform: scale(1.1); filter: drop-shadow(0 0 25px var(--nexus-neon)); }
100% { transform: scale(1); filter: drop-shadow(0 0 10px var(--nexus-neon)); }
}
.scan-line {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--nexus-neon);
box-shadow: 0 0 15px var(--nexus-neon);
animation: scan 2s infinite linear;
opacity: 0.8;
}
@keyframes scan {
0% { top: 0; }
50% { top: 100%; }
100% { top: 0; }
}
::deep .nexus-node-active {
@@ -67,9 +98,3 @@
filter: drop-shadow(0 0 12px var(--nexus-neon));
transition: all 0.3s ease;
}
.neon-glow {
color: var(--nexus-neon);
filter: drop-shadow(0 0 5px var(--nexus-neon));
}
@@ -9,6 +9,7 @@
@inject IFocusModeService FocusMode
@inject IReaderNavigationService NavigationService
@inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@if (ViewModel == null)
@@ -19,36 +20,45 @@
}
else
{
<div class="reader-flow-container">
<div @ref="_containerRef" class="reader-flow-container">
@foreach (var block in ViewModel.Blocks)
{
<div id="@block.Id" class="block-wrapper">
<div id="@block.Id" class="block-wrapper @(_highlightedBlockId == block.Id ? "highlighted" : "")">
@if (block is TextSegmentBlock textSegment)
{
<NexusTypography Variant="NexusTypography.TypographyVariant.Ebook">@((MarkupString)textSegment.Content)</NexusTypography>
}
else if (block is AiActionTriggerBlock aiTrigger)
{
<AiAssistantBubble
ContextBlockId="@block.Id"
Dialogue="@aiTrigger.Dialogue"
Actions="@aiTrigger.ActionOptions"
OnActionTriggered="HandleAiAction" />
}
</div>
}
</div>
}
<SelectionAiPanel
SelectedText="@_selectedText"
BlockId="@_selectedBlockId"
Coordinates="@_selectionCoords"
FullPageContent="@GetFullPageContent()" />
</div>
@code {
private ReaderPageViewModel? ViewModel;
private string StatusMessage = "Loading chapter...";
private string _selectedText = string.Empty;
private string _selectedBlockId = string.Empty;
private SelectionCoordinates? _selectionCoords;
private string? _highlightedBlockId;
private bool _isJsInitialized;
private ElementReference _containerRef;
protected override void OnInitialized()
{
ThemeService.OnThemeChanged += StateHasChanged;
NavigationService.OnNavigationChanged += OnNavigationChanged;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected;
}
protected override async Task OnParametersSetAsync()
@@ -58,19 +68,33 @@
private async void OnNavigationChanged()
{
_isJsInitialized = false;
_selectedText = string.Empty;
_selectionCoords = null;
await LoadChapterAsync(NavigationService.CurrentChapterIndex);
StateHasChanged();
await InitializeObserverAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
if (ViewModel != null && !_isJsInitialized)
{
_isJsInitialized = true;
await InitializeObserverAsync();
await InitializeSelectionListenerAsync();
}
}
private async Task InitializeSelectionListenerAsync()
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
await module.InvokeVoidAsync("initSelectionListener", DotNetObjectReference.Create(this), _containerRef);
}
catch { }
}
private async Task InitializeObserverAsync()
{
try
@@ -87,6 +111,49 @@
Coordinator.OnBlockReached(blockId, content);
}
[JSInvokable]
public void HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
{
Console.WriteLine($"[ReaderCanvas] Text selected: {text} at {coords.Top},{coords.Left}");
_selectedText = text;
_selectedBlockId = blockId;
_selectionCoords = coords;
StateHasChanged();
}
[JSInvokable]
public void HandleSelectionCleared()
{
_selectedText = string.Empty;
_selectionCoords = null;
StateHasChanged();
}
private void HandleScrollRequested(string blockId)
{
_ = ScrollToNodeAsync(blockId);
}
private async void HandleHighlightRequested(string blockId)
{
_highlightedBlockId = blockId;
StateHasChanged();
await Task.Delay(3000); // Highlight for 3 seconds
if (_highlightedBlockId == blockId)
{
_highlightedBlockId = null;
StateHasChanged();
}
}
private string GetFullPageContent()
{
if (ViewModel == null) return string.Empty;
return string.Join("\n\n", ViewModel.Blocks
.OfType<TextSegmentBlock>()
.Select(b => $"[ID: {b.Id}]\n{b.Content}"));
}
private async Task LoadChapterAsync(int index)
{
ViewModel = null;
@@ -97,6 +164,9 @@
{
ViewModel = result.Value;
NavigationService.UpdateMetadata(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
// Trigger full page graph generation after loading
_ = Coordinator.ProcessFullPageAsync(GetFullPageContent());
}
else
{
@@ -122,5 +192,9 @@
{
ThemeService.OnThemeChanged -= StateHasChanged;
NavigationService.OnNavigationChanged -= OnNavigationChanged;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected;
}
}
@@ -3,10 +3,64 @@
margin: 0 auto;
padding: 2rem 1rem;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.reader-flow-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
position: relative;
}
.block-wrapper {
transition: all 0.5s ease;
border-radius: 8px;
padding: 8px;
border: 1px solid transparent;
}
.block-wrapper.highlighted {
background: rgba(0, 243, 255, 0.08);
box-shadow: 0 0 20px rgba(0, 243, 255, 0.15);
border-color: #00f3ff;
transform: scale(1.01);
}
.ai-sparkle-trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(18, 18, 18, 0.6);
border: 1px solid rgba(0, 255, 153, 0.3);
border-radius: 20px;
cursor: pointer;
margin: 1rem 0;
transition: all 0.3s ease;
position: relative;
}
.ai-sparkle-trigger:hover {
background: rgba(0, 255, 153, 0.1);
border-color: #00ff99;
transform: translateY(-2px);
}
.sparkle-tooltip {
font-size: 0.75rem;
color: #fff;
opacity: 0.7;
}
.neon-pulse {
color: #00ff99;
filter: drop-shadow(0 0 5px #00ff99);
animation: pulse-small 2s infinite;
}
@keyframes pulse-small {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
@@ -6,10 +6,14 @@ public interface IKnowledgeGraphService
{
GraphDataDto? CurrentGraphData { get; }
string? ActiveNodeId { get; }
bool IsLoading { get; }
event Action? OnGraphUpdated;
event Action<string>? OnActiveNodeChanged;
event Action<bool>? OnLoadingChanged;
void UpdateGraph(GraphDataDto newData);
void SetActiveNode(string nodeId);
void SetLoading(bool isLoading);
void Clear();
}
@@ -13,7 +13,7 @@ public interface IQuizStateService
event Action? OnQuizUpdated;
void RequestQuiz(string blockId);
void SetQuiz(string blockId, QuizDto quiz);
void SetQuiz(string? blockId, QuizDto quiz);
void SetHydrating(bool hydrating);
void MarkQuizAsSeen();
}
@@ -0,0 +1,16 @@
namespace NexusReader.UI.Shared.Services;
public interface IReaderInteractionService
{
event Action<string>? OnNodeSelected;
event Action<string>? OnScrollToBlockRequested;
event Action<string>? OnHighlightBlockRequested;
event Action<string, string, SelectionCoordinates>? OnTextSelected;
void NotifyNodeSelected(string nodeId);
void RequestScrollToBlock(string blockId);
void RequestHighlightBlock(string blockId);
void NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
}
public record SelectionCoordinates(double Top, double Left, double Width);
@@ -2,6 +2,7 @@ using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Graph;
using NexusReader.Application.Queries.Quiz;
using NexusReader.UI.Shared.Services;
using NexusReader.Application.DTOs.AI;
namespace NexusReader.UI.Shared.Services;
@@ -11,131 +12,94 @@ public sealed class KnowledgeCoordinator : IDisposable
private readonly IKnowledgeGraphService _graphService;
private readonly IQuizStateService _quizService;
private readonly IPlatformService _platformService;
private readonly IReaderInteractionService _interactionService;
private CancellationTokenSource? _debounceCts;
public event Action<GraphDataDto>? OnGraphUpdated;
public KnowledgeCoordinator(
IKnowledgeService knowledgeService,
IKnowledgeGraphService graphService,
IQuizStateService quizService,
IPlatformService platformService)
IPlatformService platformService,
IReaderInteractionService interactionService)
{
_knowledgeService = knowledgeService;
_graphService = graphService;
_quizService = quizService;
_platformService = platformService;
_interactionService = interactionService;
_interactionService.OnNodeSelected += HandleNodeSelected;
}
private void HandleNodeSelected(string nodeId)
{
_interactionService.RequestScrollToBlock(nodeId);
_interactionService.RequestHighlightBlock(nodeId);
}
public async Task ProcessFullPageAsync(string fullContent)
{
if (string.IsNullOrWhiteSpace(fullContent)) return;
Console.WriteLine("[KnowledgeCoordinator] Generating full page graph...");
_graphService.Clear();
_graphService.SetLoading(true);
try
{
var result = await _knowledgeService.GetGraphDataAsync(fullContent);
if (result.IsSuccess)
{
var packet = result.Value;
if (packet.Graph != null)
{
_graphService.UpdateGraph(packet.Graph);
OnGraphUpdated?.Invoke(packet.Graph);
await _platformService.VibrateSuccessAsync();
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[KnowledgeCoordinator] Error generating graph: {ex.Message}");
}
}
public void OnBlockReached(string blockId, string content)
{
Console.WriteLine($"[KnowledgeCoordinator] Block reached: {blockId}");
// 1. Skip extraction for the title page (usually the first block or contains 'title')
if (blockId.Equals("seg-0", StringComparison.OrdinalIgnoreCase) ||
blockId.Contains("title", StringComparison.OrdinalIgnoreCase) ||
content.Length < 50) // Title pages are usually short
{
Console.WriteLine($"[KnowledgeCoordinator] Skipping extraction for title page/short block: {blockId}");
_graphService.SetActiveNode(blockId);
return;
}
// 2. Update active node immediately for "TU JESTEŚ" logic
// Only update active node for "TU JESTEŚ" logic, do NOT trigger highlight here
_graphService.SetActiveNode(blockId);
// 3. Debounce the AI extraction to prevent spamming while scrolling
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
var token = _debounceCts.Token;
_ = DebounceAndExtractAsync(blockId, content, token);
}
private async Task DebounceAndExtractAsync(string blockId, string content, CancellationToken token)
{
try
{
await Task.Delay(1000, token);
if (token.IsCancellationRequested) return;
Console.WriteLine($"[KnowledgeCoordinator] Triggering extraction for block: {blockId}");
await ProcessKnowledgeExtractionAsync(blockId, content, token);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.WriteLine($"[KnowledgeCoordinator] Unexpected error in task: {ex.Message}");
}
}
private async Task ProcessKnowledgeExtractionAsync(string blockId, string content, CancellationToken ct)
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content)
{
_quizService.SetHydrating(true);
var result = await _knowledgeService.GetKnowledgeAsync(content, ct);
if (result.IsSuccess && !ct.IsCancellationRequested)
try
{
Console.WriteLine($"[KnowledgeCoordinator] Extraction success for block: {blockId}. Updating state...");
var packet = result.Value;
// Update Quiz State
var quizQuestions = packet.Quizzes
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
.ToList();
_quizService.SetQuiz(blockId, new QuizDto(quizQuestions));
// Update Graph State
GraphDataDto graphData;
if (packet.Graph != null && packet.Graph.Nodes != null && packet.Graph.Nodes.Any())
var result = await _knowledgeService.GetSummaryAndQuizAsync(content);
if (result.IsSuccess)
{
// Use AI-generated graph
graphData = packet.Graph;
// Ensure current block is linked to the first concept or added if missing
if (!graphData.Nodes.Any(n => n.Id == blockId))
{
graphData.Nodes.Add(new GraphNodeDto(blockId, "TU JESTEŚ", "current"));
if (graphData.Nodes.Count > 1)
{
graphData.Links.Add(new GraphLinkDto(blockId, graphData.Nodes[0].Id, 1));
}
}
}
else
{
// Fallback: Transform Concepts to GraphData if AI didn't provide a graph
var nodes = packet.Concepts
.Select(c => new GraphNodeDto(c.Title.ToLowerInvariant(), c.Title, "concept"))
var packet = result.Value;
var quizQuestions = packet.Quizzes
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
.ToList();
nodes.Add(new GraphNodeDto(blockId, "TU JESTEŚ", "current"));
var links = packet.Concepts
.Select(c => new GraphLinkDto(blockId, c.Title.ToLowerInvariant(), 1))
.ToList();
graphData = new GraphDataDto { Nodes = nodes, Links = links };
_quizService.SetQuiz(null, new QuizDto(quizQuestions));
await _platformService.VibrateSuccessAsync();
return packet;
}
_graphService.UpdateGraph(graphData);
// Visual/Haptic Feedback
await _platformService.VibrateSuccessAsync();
}
else
finally
{
if (!ct.IsCancellationRequested)
{
Console.WriteLine($"[KnowledgeCoordinator] Extraction failed or returned empty for block: {blockId}. Error: {result.Errors.FirstOrDefault()?.Message}");
}
_quizService.SetHydrating(false);
}
return null;
}
public void Dispose()
{
_debounceCts?.Cancel();
_debounceCts?.Dispose();
_interactionService.OnNodeSelected -= HandleNodeSelected;
}
}
@@ -6,13 +6,17 @@ public sealed class KnowledgeGraphService : IKnowledgeGraphService
{
public GraphDataDto? CurrentGraphData { get; private set; }
public string? ActiveNodeId { get; private set; }
public bool IsLoading { get; private set; }
public event Action? OnGraphUpdated;
public event Action<string>? OnActiveNodeChanged;
public event Action<bool>? OnLoadingChanged;
public void UpdateGraph(GraphDataDto newData)
{
CurrentGraphData = newData;
IsLoading = false;
OnLoadingChanged?.Invoke(false);
OnGraphUpdated?.Invoke();
}
@@ -22,4 +26,18 @@ public sealed class KnowledgeGraphService : IKnowledgeGraphService
ActiveNodeId = nodeId;
OnActiveNodeChanged?.Invoke(nodeId);
}
public void SetLoading(bool isLoading)
{
IsLoading = isLoading;
OnLoadingChanged?.Invoke(isLoading);
}
public void Clear()
{
CurrentGraphData = null;
ActiveNodeId = null;
IsLoading = false;
OnGraphUpdated?.Invoke();
}
}
@@ -18,7 +18,7 @@ public sealed class QuizStateService : IQuizStateService
OnQuizRequested?.Invoke(blockId);
}
public void SetQuiz(string blockId, QuizDto quiz)
public void SetQuiz(string? blockId, QuizDto quiz)
{
CurrentQuizBlockId = blockId;
CurrentQuiz = quiz;
@@ -0,0 +1,29 @@
namespace NexusReader.UI.Shared.Services;
public sealed class ReaderInteractionService : IReaderInteractionService
{
public event Action<string>? OnNodeSelected;
public event Action<string>? OnScrollToBlockRequested;
public event Action<string>? OnHighlightBlockRequested;
public event Action<string, string, SelectionCoordinates>? OnTextSelected;
public void NotifyNodeSelected(string nodeId)
{
OnNodeSelected?.Invoke(nodeId);
}
public void RequestScrollToBlock(string blockId)
{
OnScrollToBlockRequested?.Invoke(blockId);
}
public void RequestHighlightBlock(string blockId)
{
OnHighlightBlockRequested?.Invoke(blockId);
}
public void NotifyTextSelected(string text, string blockId, SelectionCoordinates coords)
{
OnTextSelected?.Invoke(text, blockId, coords);
}
}
@@ -99,6 +99,10 @@ export function mount(containerId, data, dotNetHelper) {
export function updateData(data) {
if (!simulation || !rootGroup) return;
if (!data || !data.nodes) {
clear();
return;
}
// Keep existing node positions if they match by ID
const oldNodes = new Map(simulation.nodes().map(d => [d.id, d]));
@@ -261,3 +265,14 @@ export function zoomReset() {
}
}
export function clear() {
if (!rootGroup) return;
rootGroup.select(".links-layer").selectAll("path").remove();
rootGroup.select(".nodes-layer").selectAll("g.node-group").remove();
if (badge) badge.style("display", "none");
if (simulation) {
simulation.nodes([]);
simulation.force("link").links([]);
simulation.stop();
}
}
@@ -0,0 +1,41 @@
export function initSelectionListener(dotNetHelper, container) {
if (!container) return;
console.log("[SelectionHandler] Initializing...");
const handleSelection = () => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 3) {
const range = selection.getRangeAt(0);
// Look for the closest block-wrapper
let node = range.commonAncestorContainer;
if (node.nodeType !== 1) node = node.parentElement;
const blockNode = node.closest('[id]');
if (blockNode) {
const rect = range.getBoundingClientRect();
console.log("[SelectionHandler] Selection at screen coords:", rect.top, rect.left);
dotNetHelper.invokeMethodAsync('HandleTextSelected',
text,
blockNode.id,
{
Top: rect.top,
Left: rect.left,
Width: rect.width
});
}
} else {
dotNetHelper.invokeMethodAsync('HandleSelectionCleared');
}
};
// Use multiple triggers for maximum reliability
document.addEventListener('selectionchange', handleSelection);
container.addEventListener('mouseup', () => setTimeout(handleSelection, 10));
}
+1
View File
@@ -14,6 +14,7 @@ builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
@@ -15,25 +15,36 @@ public class WasmKnowledgeService : IKnowledgeService
}
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default)
{
return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken);
}
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default)
{
return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken);
}
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default)
{
return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken);
}
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken)
{
try
{
Console.WriteLine($"[WasmKnowledgeService] Calling API for extraction (Text length: {text.Length})...");
var response = await _httpClient.PostAsJsonAsync("/api/knowledge", new { text }, cancellationToken);
var response = await _httpClient.PostAsJsonAsync(endpoint, new { text }, cancellationToken);
if (response.IsSuccessStatusCode)
{
Console.WriteLine("[WasmKnowledgeService] API Response success. Deserializing...");
var packet = await response.Content.ReadFromJsonAsync<KnowledgePacket>(cancellationToken: cancellationToken);
return packet != null ? Result.Ok(packet) : Result.Fail("Failed to deserialize knowledge packet.");
}
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
Console.WriteLine($"[WasmKnowledgeService] API Error ({response.StatusCode}): {errorBody}");
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
}
catch (Exception ex)
{
Console.WriteLine($"[WasmKnowledgeService] Exception: {ex.Message}");
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
}
}
+15 -2
View File
@@ -25,6 +25,7 @@ builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddApplication();
@@ -73,9 +74,21 @@ app.MapPost("/api/knowledge", async (KnowledgeRequest request, IKnowledgeService
{
var result = await knowledgeService.GetKnowledgeAsync(request.Text);
if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error");
});
var errorMsg = result.Errors.FirstOrDefault()?.Message ?? "Unknown server error";
return Results.BadRequest(errorMsg);
app.MapPost("/api/knowledge/graph", async (KnowledgeRequest request, IKnowledgeService knowledgeService) =>
{
var result = await knowledgeService.GetGraphDataAsync(request.Text);
if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error");
});
app.MapPost("/api/knowledge/summary", async (KnowledgeRequest request, IKnowledgeService knowledgeService) =>
{
var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text);
if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error");
});
app.MapDelete("/api/knowledge", async (IKnowledgeService knowledgeService) =>
+1 -1
View File
@@ -13,7 +13,7 @@
"Google": {
"ApiKey": "PLACEHOLDER",
"Model": "gemini-2.5-flash-lite",
"MaxOutputTokens": 4096
"MaxOutputTokens": 8192
}
}
}
Binary file not shown.