From 39a9ca5706b823c13b8b17dd399f4ec1deb76f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 26 Apr 2026 20:36:08 +0200 Subject: [PATCH] feat: integrate AI-driven selection panel with context-aware text summarization and quiz generation features. --- .../Services/IKnowledgeService.cs | 2 + .../DTOs/AI/KnowledgePacket.cs | 1 + .../Helpers/JsonRepairHelper.cs | 69 ++++++++ .../Services/KnowledgeService.cs | 122 ++++++-------- .../Services/PromptRegistry.cs | 11 ++ .../Molecules/SelectionAiPanel.razor | 96 +++++++++++ .../Molecules/SelectionAiPanel.razor.css | 158 ++++++++++++++++++ .../Components/Organisms/KnowledgeGraph.razor | 54 +++--- .../Organisms/KnowledgeGraph.razor.css | 51 ++++-- .../Components/Organisms/ReaderCanvas.razor | 98 +++++++++-- .../Organisms/ReaderCanvas.razor.css | 54 ++++++ .../Services/IKnowledgeGraphService.cs | 6 +- .../Services/IQuizStateService.cs | 2 +- .../Services/IReaderInteractionService.cs | 16 ++ .../Services/KnowledgeCoordinator.cs | 150 +++++++---------- .../Services/KnowledgeGraphService.cs | 18 ++ .../Services/QuizStateService.cs | 2 +- .../Services/ReaderInteractionService.cs | 29 ++++ .../wwwroot/js/knowledgeGraph.js | 15 ++ .../wwwroot/js/selectionHandler.js | 41 +++++ src/NexusReader.Web.Client/Program.cs | 1 + .../Services/WasmKnowledgeService.cs | 21 ++- src/NexusReader.Web.New/Program.cs | 19 ++- src/NexusReader.Web.New/appsettings.json | 2 +- src/NexusReader.Web.New/nexus.db | Bin 57344 -> 0 bytes 25 files changed, 819 insertions(+), 219 deletions(-) create mode 100644 src/NexusReader.Infrastructure/Helpers/JsonRepairHelper.cs create mode 100644 src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor create mode 100644 src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor.css create mode 100644 src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs create mode 100644 src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs create mode 100644 src/NexusReader.UI.Shared/wwwroot/js/selectionHandler.js delete mode 100644 src/NexusReader.Web.New/nexus.db diff --git a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs index f3d1100..b6f603f 100644 --- a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs @@ -6,5 +6,7 @@ namespace NexusReader.Application.Abstractions.Services; public interface IKnowledgeService { Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default); + Task> GetGraphDataAsync(string text, CancellationToken cancellationToken = default); + Task> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default); Task ClearCacheAsync(CancellationToken cancellationToken = default); } diff --git a/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs b/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs index a6a1c23..d0b1f8b 100644 --- a/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs +++ b/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs @@ -18,4 +18,5 @@ public record KnowledgePacket [JsonPropertyName("concepts")] public List Concepts { get; init; } = new(); [JsonPropertyName("quizzes")] public List Quizzes { get; init; } = new(); [JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; } + [JsonPropertyName("summary")] public string? Summary { get; init; } } diff --git a/src/NexusReader.Infrastructure/Helpers/JsonRepairHelper.cs b/src/NexusReader.Infrastructure/Helpers/JsonRepairHelper.cs new file mode 100644 index 0000000..7e4b3d0 --- /dev/null +++ b/src/NexusReader.Infrastructure/Helpers/JsonRepairHelper.cs @@ -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(); + 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; + } +} diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 0643d39..ad74f13 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -35,29 +35,37 @@ public class KnowledgeService : IKnowledgeService } public async Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default) + { + return await GetKnowledgeInternalAsync(text, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken); + } + + public async Task> GetGraphDataAsync(string text, CancellationToken cancellationToken = default) + { + return await GetKnowledgeInternalAsync(text, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken); + } + + public async Task> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default) + { + return await GetKnowledgeInternalAsync(text, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken); + } + + private async Task> 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(cached.JsonData); - if (packet != null) - { - return Result.Ok(packet); - } - } - catch (JsonException ex) - { - Console.WriteLine($"[KnowledgeService] Cache deserialization error: {ex.Message}"); + var packet = JsonSerializer.Deserialize(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 { - 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(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(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}"); } } diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index 08e1023..7b283ad 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -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 } ] }"; } diff --git a/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor new file mode 100644 index 0000000..b7f4513 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor @@ -0,0 +1,96 @@ +@using NexusReader.UI.Shared.Services +@using NexusReader.Application.DTOs.AI +@inject KnowledgeCoordinator Coordinator +@inject IReaderInteractionService InteractionService + +@if (IsVisible) +{ +
+
+
+
+ +
+ E-Czytnik + Asystent AI +
+
+
+ @if (IsLoading) + { +
+
Skanowanie fragmentu...
+
+ } + else if (Packet != null) + { +
+ @Packet.Summary +
+
+ + +
+ } + else + { +
+ Wykryto ciekawy fragment! Czy chcesz, abym wygenerował podsumowanie lub quiz z tego rozdziału? +
+
+ + +
+ } +
+
+
+
+} + +@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!); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor.css new file mode 100644 index 0000000..f99e021 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor.css @@ -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; } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor index 2fa79a7..a010a46 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor @@ -7,13 +7,17 @@ @inject IJSRuntime JS @inject IFocusModeService FocusMode @inject IKnowledgeGraphService GraphService +@inject IReaderInteractionService InteractionService
- @if (GraphData == null) + @if (GraphService.IsLoading || GraphService.CurrentGraphData == null) {
- - Analyzing Chapter Nodes... +
+ +
+
+ Mapowanie relacji rozdziału...
} else @@ -31,7 +35,6 @@ [Parameter] public EventCallback OnNodeSelected { get; set; } private string ContainerId = "d3-graph-container"; - private GraphDataDto? GraphData; private IJSObjectReference? _module; private DotNetObjectReference? _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("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(); } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css index d9665c7..ed59fd1 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css @@ -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)); -} - diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index 873da36..1e77c90 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -9,6 +9,7 @@ @inject IFocusModeService FocusMode @inject IReaderNavigationService NavigationService @inject KnowledgeCoordinator Coordinator +@inject IReaderInteractionService InteractionService
@if (ViewModel == null) @@ -19,36 +20,45 @@ } else { -
+
@foreach (var block in ViewModel.Blocks) { -
+
@if (block is TextSegmentBlock textSegment) { @((MarkupString)textSegment.Content) } - else if (block is AiActionTriggerBlock aiTrigger) - { - - }
}
} + +
@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("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() + .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; } } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css index 0828078..b718676 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css @@ -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; } } \ No newline at end of file diff --git a/src/NexusReader.UI.Shared/Services/IKnowledgeGraphService.cs b/src/NexusReader.UI.Shared/Services/IKnowledgeGraphService.cs index 9993be4..6db8c10 100644 --- a/src/NexusReader.UI.Shared/Services/IKnowledgeGraphService.cs +++ b/src/NexusReader.UI.Shared/Services/IKnowledgeGraphService.cs @@ -6,10 +6,14 @@ public interface IKnowledgeGraphService { GraphDataDto? CurrentGraphData { get; } string? ActiveNodeId { get; } + bool IsLoading { get; } event Action? OnGraphUpdated; event Action? OnActiveNodeChanged; - + event Action? OnLoadingChanged; + void UpdateGraph(GraphDataDto newData); void SetActiveNode(string nodeId); + void SetLoading(bool isLoading); + void Clear(); } diff --git a/src/NexusReader.UI.Shared/Services/IQuizStateService.cs b/src/NexusReader.UI.Shared/Services/IQuizStateService.cs index e3a5ffc..571719e 100644 --- a/src/NexusReader.UI.Shared/Services/IQuizStateService.cs +++ b/src/NexusReader.UI.Shared/Services/IQuizStateService.cs @@ -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(); } diff --git a/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs b/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs new file mode 100644 index 0000000..c8ead52 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs @@ -0,0 +1,16 @@ +namespace NexusReader.UI.Shared.Services; + +public interface IReaderInteractionService +{ + event Action? OnNodeSelected; + event Action? OnScrollToBlockRequested; + event Action? OnHighlightBlockRequested; + event Action? 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); diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs index 629eeb2..60c4737 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs @@ -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? 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 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; } } diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeGraphService.cs b/src/NexusReader.UI.Shared/Services/KnowledgeGraphService.cs index ad8970a..a96b85b 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeGraphService.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeGraphService.cs @@ -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? OnActiveNodeChanged; + public event Action? 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(); + } } diff --git a/src/NexusReader.UI.Shared/Services/QuizStateService.cs b/src/NexusReader.UI.Shared/Services/QuizStateService.cs index db42467..81a3435 100644 --- a/src/NexusReader.UI.Shared/Services/QuizStateService.cs +++ b/src/NexusReader.UI.Shared/Services/QuizStateService.cs @@ -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; diff --git a/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs b/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs new file mode 100644 index 0000000..e26e045 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs @@ -0,0 +1,29 @@ +namespace NexusReader.UI.Shared.Services; + +public sealed class ReaderInteractionService : IReaderInteractionService +{ + public event Action? OnNodeSelected; + public event Action? OnScrollToBlockRequested; + public event Action? OnHighlightBlockRequested; + public event Action? 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); + } +} diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index e6ca24b..eb46cd1 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -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(); + } +} diff --git a/src/NexusReader.UI.Shared/wwwroot/js/selectionHandler.js b/src/NexusReader.UI.Shared/wwwroot/js/selectionHandler.js new file mode 100644 index 0000000..f054250 --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/js/selectionHandler.js @@ -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)); +} diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index 1ae8023..cc9d139 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -14,6 +14,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs b/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs index 9f1af24..afadcc8 100644 --- a/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs +++ b/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs @@ -15,25 +15,36 @@ public class WasmKnowledgeService : IKnowledgeService } public async Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default) + { + return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken); + } + + public async Task> GetGraphDataAsync(string text, CancellationToken cancellationToken = default) + { + return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken); + } + + public async Task> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default) + { + return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken); + } + + private async Task> 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(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)); } } diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index ee2c6cc..79e9c3a 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); 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); - - var errorMsg = result.Errors.FirstOrDefault()?.Message ?? "Unknown server error"; - return Results.BadRequest(errorMsg); + return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Unknown server error"); +}); + +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) => diff --git a/src/NexusReader.Web.New/appsettings.json b/src/NexusReader.Web.New/appsettings.json index be18923..0af7df8 100644 --- a/src/NexusReader.Web.New/appsettings.json +++ b/src/NexusReader.Web.New/appsettings.json @@ -13,7 +13,7 @@ "Google": { "ApiKey": "PLACEHOLDER", "Model": "gemini-2.5-flash-lite", - "MaxOutputTokens": 4096 + "MaxOutputTokens": 8192 } } } diff --git a/src/NexusReader.Web.New/nexus.db b/src/NexusReader.Web.New/nexus.db deleted file mode 100644 index 86c2fa853f2d2b4892c32ff4bbd0ff8274149c08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57344 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