feat: integrate AI-driven selection panel with context-aware text summarization and quiz generation features.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"Google": {
|
||||
"ApiKey": "PLACEHOLDER",
|
||||
"Model": "gemini-2.5-flash-lite",
|
||||
"MaxOutputTokens": 4096
|
||||
"MaxOutputTokens": 8192
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user