feat: implement asynchronous text selection summarization with real-time UI updates and skeleton loading states
This commit is contained in:
@@ -107,13 +107,20 @@
|
||||
|
||||
try
|
||||
{
|
||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||
: "";
|
||||
|
||||
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
|
||||
if (result.IsSuccess)
|
||||
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)
|
||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||
: "";
|
||||
|
||||
_ = Coordinator.StartSelectionSummaryAsync($"{contextPrompt}{selectedText}");
|
||||
await CloseAsync();
|
||||
await InteractionService.RequestAssistant();
|
||||
}
|
||||
@@ -137,15 +144,25 @@
|
||||
|
||||
try
|
||||
{
|
||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||
: "";
|
||||
|
||||
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{SelectedText}");
|
||||
if (result.IsSuccess)
|
||||
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))
|
||||
{
|
||||
await CloseAsync();
|
||||
await QuizService.RequestQuiz(BlockId);
|
||||
selectedText = SelectedText;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selectedText))
|
||||
{
|
||||
var contextPrompt = !string.IsNullOrWhiteSpace(FullPageContent)
|
||||
? $"ANALYSIS CONTEXT (Full Page Content):\n{FullPageContent}\n\nUSER SELECTION TO SUMMARIZE:\n"
|
||||
: "";
|
||||
|
||||
var result = await Coordinator.RequestSummaryAndQuizAsync($"{contextPrompt}{selectedText}");
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await CloseAsync();
|
||||
await QuizService.RequestQuiz(BlockId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
|
||||
@inject IThemeService ThemeService
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@implements IAsyncDisposable
|
||||
|
||||
|
||||
@@ -63,7 +64,32 @@
|
||||
<span class="panel-title">Contextual Intelligence Panel</span>
|
||||
</div>
|
||||
<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-header-section">
|
||||
@@ -166,7 +192,32 @@
|
||||
{
|
||||
<div class="contextual-intelligence-panel">
|
||||
<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-header-section">
|
||||
@@ -293,6 +344,7 @@
|
||||
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
|
||||
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
|
||||
ThemeService.OnThemeChanged += HandleThemeChangedAsync;
|
||||
Coordinator.OnSelectionSummaryStateChanged += HandleUpdate;
|
||||
|
||||
var context = PlatformService.GetDeviceContext();
|
||||
if (context.IsSuccess)
|
||||
@@ -333,6 +385,11 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ClearSelectionSummary()
|
||||
{
|
||||
await Coordinator.ClearSelectionSummaryAsync();
|
||||
}
|
||||
|
||||
private async Task HandleScrollPercentChanged(int percent)
|
||||
{
|
||||
_scrollPercentage = percent;
|
||||
@@ -353,12 +410,24 @@
|
||||
{
|
||||
if (_isMobile)
|
||||
{
|
||||
if (Coordinator.IsLoadingSelectionSummary || !string.IsNullOrEmpty(Coordinator.SelectionSummary))
|
||||
{
|
||||
_activeMobileTab = MobileReaderTab.Concepts;
|
||||
_activeTab = SidebarTab.Knowledge;
|
||||
}
|
||||
OpenAssistant();
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeMobileTab = MobileReaderTab.Concepts;
|
||||
_activeTab = SidebarTab.Quiz;
|
||||
if (Coordinator.IsLoadingSelectionSummary || !string.IsNullOrEmpty(Coordinator.SelectionSummary))
|
||||
{
|
||||
_activeTab = SidebarTab.Knowledge;
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeTab = SidebarTab.Quiz;
|
||||
}
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
@@ -450,6 +519,7 @@
|
||||
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
|
||||
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
||||
ThemeService.OnThemeChanged -= HandleThemeChangedAsync;
|
||||
Coordinator.OnSelectionSummaryStateChanged -= HandleUpdate;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -705,3 +705,66 @@ main {
|
||||
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 bool IsLoadingSelectionSummary { get; private set; }
|
||||
public string? SelectionSummary { get; private set; }
|
||||
public string? SelectedTextContext { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the knowledge graph has been updated with new data.
|
||||
/// Subscribers must return a Task to enable proper async handling.
|
||||
/// </summary>
|
||||
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(
|
||||
IKnowledgeService knowledgeService,
|
||||
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()
|
||||
{
|
||||
CancelAndDisposeCts(ref _graphCts);
|
||||
@@ -213,6 +263,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
|
||||
CurrentFullPageContent = string.Empty;
|
||||
await _graphService.Clear();
|
||||
await _quizService.SetQuiz(null, null);
|
||||
await ClearSelectionSummaryAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -34,9 +34,11 @@ export function positionToolbar() {
|
||||
const relativeTop = firstRect.top - toolbarHeight - 14;
|
||||
|
||||
let top;
|
||||
let below = false;
|
||||
if (relativeTop < 0) {
|
||||
// Pozwól wskoczyć POD zaznaczony tekst
|
||||
top = (combinedRect.bottom - canvasRect.top) + scrollTop + 12;
|
||||
below = true;
|
||||
toolbarElement.classList.add('below');
|
||||
} else {
|
||||
top = (firstRect.top - canvasRect.top) + scrollTop - toolbarHeight - 14;
|
||||
@@ -45,6 +47,12 @@ export function positionToolbar() {
|
||||
|
||||
toolbarElement.style.left = `${left}px`;
|
||||
toolbarElement.style.top = `${top}px`;
|
||||
|
||||
return {
|
||||
left: left,
|
||||
top: top,
|
||||
below: below
|
||||
};
|
||||
}
|
||||
|
||||
export function initSelectionListener(dotNetHelper, container) {
|
||||
@@ -101,3 +109,7 @@ export function initSelectionListener(dotNetHelper, container) {
|
||||
container.addEventListener('mouseup', () => setTimeout(handleSelection, 10));
|
||||
}
|
||||
|
||||
export function getSelectionText() {
|
||||
return window.getSelection().toString();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user