feat: implement asynchronous text selection summarization with real-time UI updates and skeleton loading states

This commit is contained in:
2026-06-03 15:02:23 +02:00
parent 2ef8dd4066
commit 89fa5cac19
5 changed files with 230 additions and 17 deletions
@@ -106,14 +106,21 @@
StateHasChanged(); StateHasChanged();
try try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
var selectedText = await module.InvokeAsync<string>("getSelectionText");
if (string.IsNullOrWhiteSpace(selectedText))
{
selectedText = SelectedText;
}
if (!string.IsNullOrWhiteSpace(selectedText))
{ {
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent) var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n" ? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: ""; : "";
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}"); _ = Coordinator.StartSelectionSummaryAsync($"{contextPrompt}{selectedText}");
if (result.IsSuccess)
{
await CloseAsync(); await CloseAsync();
await InteractionService.RequestAssistant(); await InteractionService.RequestAssistant();
} }
@@ -136,18 +143,28 @@
StateHasChanged(); StateHasChanged();
try try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
var selectedText = await module.InvokeAsync<string>("getSelectionText");
if (string.IsNullOrWhiteSpace(selectedText))
{
selectedText = SelectedText;
}
if (!string.IsNullOrWhiteSpace(selectedText))
{ {
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent) var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n" ? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
: ""; : "";
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}"); var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{selectedText}");
if (result.IsSuccess) if (result.IsSuccess)
{ {
await CloseAsync(); await CloseAsync();
await QuizService.RequestQuiz(BlockId); await QuizService.RequestQuiz(BlockId);
} }
} }
}
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[SelectionAiPanel] Error generating quiz: {ex.Message}"); Console.WriteLine($"[SelectionAiPanel] Error generating quiz: {ex.Message}");
@@ -17,6 +17,7 @@
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger @inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
@inject IThemeService ThemeService @inject IThemeService ThemeService
@inject KnowledgeCoordinator Coordinator
@implements IAsyncDisposable @implements IAsyncDisposable
@@ -63,7 +64,32 @@
<span class="panel-title">Contextual Intelligence Panel</span> <span class="panel-title">Contextual Intelligence Panel</span>
</div> </div>
<div class="panel-body"> <div class="panel-body">
@if (_selectedNode != null) @if (Coordinator.IsLoadingSelectionSummary)
{
<div class="skeleton-container">
<div class="skeleton-line title"></div>
<div class="skeleton-line w-90"></div>
<div class="skeleton-line w-80"></div>
<div class="skeleton-line w-70"></div>
<div class="skeleton-line w-60"></div>
</div>
}
else if (!string.IsNullOrEmpty(Coordinator.SelectionSummary))
{
<div class="node-details">
<div class="node-header-section">
<div class="summary-badge-row">
<span class="node-group-badge current">PODSUMOWANIE</span>
<button class="clear-summary-btn" @onclick="ClearSelectionSummary" title="Wyczyść podsumowanie">×</button>
</div>
<h3 class="node-label">Zaznaczony Fragment</h3>
</div>
<div class="detail-section summary-section">
<p class="node-summary">@Coordinator.SelectionSummary</p>
</div>
</div>
}
else if (_selectedNode != null)
{ {
<div class="node-details"> <div class="node-details">
<div class="node-header-section"> <div class="node-header-section">
@@ -166,7 +192,32 @@
{ {
<div class="contextual-intelligence-panel"> <div class="contextual-intelligence-panel">
<div class="panel-body"> <div class="panel-body">
@if (_selectedNode != null) @if (Coordinator.IsLoadingSelectionSummary)
{
<div class="skeleton-container">
<div class="skeleton-line title"></div>
<div class="skeleton-line w-90"></div>
<div class="skeleton-line w-80"></div>
<div class="skeleton-line w-70"></div>
<div class="skeleton-line w-60"></div>
</div>
}
else if (!string.IsNullOrEmpty(Coordinator.SelectionSummary))
{
<div class="node-details">
<div class="node-header-section">
<div class="summary-badge-row">
<span class="node-group-badge current">PODSUMOWANIE</span>
<button class="clear-summary-btn" @onclick="ClearSelectionSummary" title="Wyczyść podsumowanie">×</button>
</div>
<h3 class="node-label">Zaznaczony Fragment</h3>
</div>
<div class="detail-section summary-section">
<p class="node-summary">@Coordinator.SelectionSummary</p>
</div>
</div>
}
else if (_selectedNode != null)
{ {
<div class="node-details"> <div class="node-details">
<div class="node-header-section"> <div class="node-header-section">
@@ -293,6 +344,7 @@
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged; InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync; GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
ThemeService.OnThemeChanged += HandleThemeChangedAsync; ThemeService.OnThemeChanged += HandleThemeChangedAsync;
Coordinator.OnSelectionSummaryStateChanged += HandleUpdate;
var context = PlatformService.GetDeviceContext(); var context = PlatformService.GetDeviceContext();
if (context.IsSuccess) if (context.IsSuccess)
@@ -333,6 +385,11 @@
StateHasChanged(); StateHasChanged();
} }
private async Task ClearSelectionSummary()
{
await Coordinator.ClearSelectionSummaryAsync();
}
private async Task HandleScrollPercentChanged(int percent) private async Task HandleScrollPercentChanged(int percent)
{ {
_scrollPercentage = percent; _scrollPercentage = percent;
@@ -353,13 +410,25 @@
{ {
if (_isMobile) if (_isMobile)
{ {
if (Coordinator.IsLoadingSelectionSummary || !string.IsNullOrEmpty(Coordinator.SelectionSummary))
{
_activeMobileTab = MobileReaderTab.Concepts;
_activeTab = SidebarTab.Knowledge;
}
OpenAssistant(); OpenAssistant();
} }
else else
{ {
_activeMobileTab = MobileReaderTab.Concepts; _activeMobileTab = MobileReaderTab.Concepts;
if (Coordinator.IsLoadingSelectionSummary || !string.IsNullOrEmpty(Coordinator.SelectionSummary))
{
_activeTab = SidebarTab.Knowledge;
}
else
{
_activeTab = SidebarTab.Quiz; _activeTab = SidebarTab.Quiz;
} }
}
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -450,6 +519,7 @@
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged; InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync; GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
ThemeService.OnThemeChanged -= HandleThemeChangedAsync; ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate;
try try
{ {
@@ -705,3 +705,66 @@ main {
background: #f9f9f9; background: #f9f9f9;
} }
/* Skeleton Loader for Selection Summary */
.skeleton-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 0;
}
.skeleton-line {
height: 0.75rem;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite linear;
border-radius: 4px;
}
.skeleton-line.title {
height: 1.25rem;
width: 60%;
margin-bottom: 0.5rem;
}
.skeleton-line.w-90 { width: 90%; }
.skeleton-line.w-80 { width: 80%; }
.skeleton-line.w-70 { width: 70%; }
.skeleton-line.w-60 { width: 60%; }
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.summary-badge-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
/* Clear Summary Button styling */
.clear-summary-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
padding: 0.2rem 0.4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.clear-summary-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
@@ -22,12 +22,21 @@ public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
public string CurrentFullPageContent { get; private set; } = string.Empty; public string CurrentFullPageContent { get; private set; } = string.Empty;
public bool IsLoadingSelectionSummary { get; private set; }
public string? SelectionSummary { get; private set; }
public string? SelectedTextContext { get; private set; }
/// <summary> /// <summary>
/// Raised when the knowledge graph has been updated with new data. /// Raised when the knowledge graph has been updated with new data.
/// Subscribers must return a Task to enable proper async handling. /// Subscribers must return a Task to enable proper async handling.
/// </summary> /// </summary>
public event Func<GraphDataDto, Task>? OnGraphUpdated; public event Func<GraphDataDto, Task>? OnGraphUpdated;
/// <summary>
/// Raised when the selection summary state has changed (loading started, finished, or cleared).
/// </summary>
public event Func<Task>? OnSelectionSummaryStateChanged;
public KnowledgeCoordinator( public KnowledgeCoordinator(
IKnowledgeService knowledgeService, IKnowledgeService knowledgeService,
IKnowledgeGraphService graphService, IKnowledgeGraphService graphService,
@@ -205,6 +214,47 @@ public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
} }
} }
public async Task StartSelectionSummaryAsync(string text, string tenantId = "global")
{
if (string.IsNullOrWhiteSpace(text)) return;
IsLoadingSelectionSummary = true;
SelectionSummary = null;
SelectedTextContext = text;
if (OnSelectionSummaryStateChanged != null)
{
await OnSelectionSummaryStateChanged.Invoke();
}
try
{
var result = await RequestSummaryAndQuizAsync(text, tenantId);
if (result.IsSuccess)
{
SelectionSummary = result.Value.Summary;
}
}
finally
{
IsLoadingSelectionSummary = false;
if (OnSelectionSummaryStateChanged != null)
{
await OnSelectionSummaryStateChanged.Invoke();
}
}
}
public async Task ClearSelectionSummaryAsync()
{
SelectionSummary = null;
SelectedTextContext = null;
IsLoadingSelectionSummary = false;
if (OnSelectionSummaryStateChanged != null)
{
await OnSelectionSummaryStateChanged.Invoke();
}
}
public async Task ClearAsync() public async Task ClearAsync()
{ {
CancelAndDisposeCts(ref _graphCts); CancelAndDisposeCts(ref _graphCts);
@@ -213,6 +263,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
CurrentFullPageContent = string.Empty; CurrentFullPageContent = string.Empty;
await _graphService.Clear(); await _graphService.Clear();
await _quizService.SetQuiz(null, null); await _quizService.SetQuiz(null, null);
await ClearSelectionSummaryAsync();
} }
public void Dispose() public void Dispose()
@@ -34,9 +34,11 @@ export function positionToolbar() {
const relativeTop = firstRect.top - toolbarHeight - 14; const relativeTop = firstRect.top - toolbarHeight - 14;
let top; let top;
let below = false;
if (relativeTop < 0) { if (relativeTop < 0) {
// Pozwól wskoczyć POD zaznaczony tekst // Pozwól wskoczyć POD zaznaczony tekst
top = (combinedRect.bottom - canvasRect.top) + scrollTop + 12; top = (combinedRect.bottom - canvasRect.top) + scrollTop + 12;
below = true;
toolbarElement.classList.add('below'); toolbarElement.classList.add('below');
} else { } else {
top = (firstRect.top - canvasRect.top) + scrollTop - toolbarHeight - 14; top = (firstRect.top - canvasRect.top) + scrollTop - toolbarHeight - 14;
@@ -45,6 +47,12 @@ export function positionToolbar() {
toolbarElement.style.left = `${left}px`; toolbarElement.style.left = `${left}px`;
toolbarElement.style.top = `${top}px`; toolbarElement.style.top = `${top}px`;
return {
left: left,
top: top,
below: below
};
} }
export function initSelectionListener(dotNetHelper, container) { export function initSelectionListener(dotNetHelper, container) {
@@ -101,3 +109,7 @@ export function initSelectionListener(dotNetHelper, container) {
container.addEventListener('mouseup', () => setTimeout(handleSelection, 10)); container.addEventListener('mouseup', () => setTimeout(handleSelection, 10));
} }
export function getSelectionText() {
return window.getSelection().toString();
}