feat: implement asynchronous text selection summarization with real-time UI updates and skeleton loading states
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user