711822f5de
This PR provides critical stabilization, memory leak resolution, and security enhancements for the NexusReader application, specifically focusing on Blazor InteractiveAuto lifecycle safety, thread-safe automated authentication token refresh, and deduplication of active AI service queries. ### Key Enhancements #### 1. Security & Lifecycle Stabilization (`AuthenticationHeaderHandler.cs` & `Library.razor`) * **Secure Token Propagation (CWE-200)**: Modified the outbound delegating handler to only append JWT Bearer headers to trusted base origin requests matching the application's configured `NavigationManager.BaseUri`, preventing potential token leakage to external services. * **Captive Dependency & Memory Leak Fix (CWE-400)**: Avoided capturing scoped dependencies in a singleton handler by wrapping the resolution of `IIdentityService` inside a dedicated, disposable `IServiceProvider` scope (`_serviceProvider.CreateScope()`). * **Thread-Safe Automated Refresh**: Embedded a `SemaphoreSlim` lock around the automated `RefreshTokenAsync` renewal sequence to handle concurrent API requests gracefully without triggering duplicate token refresh attempts. * **Pre-rendering Safety**: Deferred the secure book loading query in `Library.razor` from `OnInitializedAsync` to client-side `OnAfterRenderAsync(firstRender: true)` to avoid inevitable `401 Unauthorized` responses and logs during the server pre-rendering phase. #### 2. Robust AI Request Deduplication (`KnowledgeService.cs`) * **State Recovery Guards**: Enhanced the thread-safe `Lazy<Task<Result<KnowledgePacket>>>` deduplication map by adding thorough failure handling blocks. Active requests are guaranteed to be cleaned up (`TryRemove`) inside `finally` and failed results pathways, ensuring future retries can run immediately if an initial request encounters an error. #### 3. Idempotent AI UI Fetching & JSRuntime Guards * **Interactive Guards**: Added an `_isInteractive` check to `GroundednessBadge.razor` and `AiAssistantBubble.razor` components, deferring WebAssembly API executions and DOM updates to client-side `OnAfterRenderAsync`. * **State Synchronization**: Integrated a synchronous `OnParametersSet` to properly reset groundedness badges when content changes. * **Flicker Elimination**: Moved JSRuntime local-storage checks in `Home.razor` (for focus mode preferences) to `OnAfterRenderAsync(firstRender: true)`, resolving startup JSInterop exceptions and eliminating layout shifts. ### Verification Performed * Mandatory build gate verified: `Kompilacja powiodła się.` with zero compile errors (`dotnet build NexusReader.slnx --no-restore`). * Validated dependency resolution patterns and async safety (no `async void`). --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #45 Reviewed-by: Marek Jaisński <jasins.marek@gmail.com> Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
165 lines
5.4 KiB
Plaintext
165 lines
5.4 KiB
Plaintext
@using NexusReader.UI.Shared.Services
|
|
@using NexusReader.Application.DTOs.AI
|
|
@inject IQuizStateService QuizState
|
|
@inject KnowledgeCoordinator Coordinator
|
|
@implements IDisposable
|
|
|
|
<div class="ai-bubble-container">
|
|
<div class="ai-bubble">
|
|
<div class="ai-avatar">
|
|
<div class="avatar-ring"></div>
|
|
<NexusIcon Name="robot" Size="48" Class="@(_isStreaming ? "neon-pulse" : "neon-glow")" />
|
|
<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">Analizuję fragment...</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">
|
|
@_displayedText@(_isStreaming ? "▍" : "")
|
|
</NexusTypography>
|
|
}
|
|
|
|
<div class="ai-actions">
|
|
<button class="action-btn ghost" @onclick='() => HandleActionClick("more")'>Pokaż więcej informacji</button>
|
|
<button class="action-btn neon-border" @onclick='() => HandleActionClick("quiz")' disabled="@(_isLoading)">Rozwiąż quiz</button>
|
|
</div>
|
|
</div>
|
|
<div class="bubble-pointer"></div>
|
|
</div>
|
|
</div>
|
|
|
|
@code {
|
|
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
|
/// <summary>Fallback static dialogue shown when no live AI content is available.</summary>
|
|
[Parameter] public string Dialogue { get; set; } = string.Empty;
|
|
[Parameter] public List<string> Actions { get; set; } = new();
|
|
[Parameter] public string FullPageContent { get; set; } = string.Empty;
|
|
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
|
|
|
|
private string _displayedText = string.Empty;
|
|
private bool _isLoading = false;
|
|
private bool _isStreaming = false;
|
|
private string _lastFetchedBlockId = string.Empty;
|
|
private KnowledgePacket? _packet;
|
|
private CancellationTokenSource? _streamCts;
|
|
private bool _isInteractive;
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (!_isInteractive)
|
|
return;
|
|
|
|
// Only re-fetch when the block context actually changes
|
|
if (string.IsNullOrEmpty(ContextBlockId) || ContextBlockId == _lastFetchedBlockId)
|
|
return;
|
|
|
|
_lastFetchedBlockId = ContextBlockId;
|
|
await FetchAndStreamAsync();
|
|
}
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (firstRender)
|
|
{
|
|
_isInteractive = true;
|
|
if (!string.IsNullOrEmpty(ContextBlockId))
|
|
{
|
|
_lastFetchedBlockId = ContextBlockId;
|
|
await FetchAndStreamAsync();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task FetchAndStreamAsync()
|
|
{
|
|
// Cancel any in-progress stream
|
|
_streamCts?.Cancel();
|
|
_streamCts = new CancellationTokenSource();
|
|
var token = _streamCts.Token;
|
|
|
|
_isLoading = true;
|
|
_isStreaming = false;
|
|
_displayedText = string.Empty;
|
|
_packet = null;
|
|
StateHasChanged();
|
|
|
|
try
|
|
{
|
|
var contentToAnalyze = !string.IsNullOrWhiteSpace(FullPageContent)
|
|
? FullPageContent
|
|
: $"[ID: {ContextBlockId}]\n{Dialogue}";
|
|
|
|
var result = await Coordinator.RequestSummaryAndQuizAsync(contentToAnalyze);
|
|
_packet = result.IsSuccess ? result.Value : null;
|
|
|
|
var summary = _packet?.Summary;
|
|
|
|
if (string.IsNullOrWhiteSpace(summary))
|
|
{
|
|
// Fall back to the static Dialogue parameter
|
|
_displayedText = string.IsNullOrEmpty(Dialogue)
|
|
? "Brak danych do analizy."
|
|
: Dialogue;
|
|
_isLoading = false;
|
|
StateHasChanged();
|
|
return;
|
|
}
|
|
|
|
_isLoading = false;
|
|
_isStreaming = true;
|
|
|
|
// Word-by-word reveal (streaming simulation)
|
|
var words = summary.Split(' ');
|
|
foreach (var word in words)
|
|
{
|
|
if (token.IsCancellationRequested) break;
|
|
_displayedText += (string.IsNullOrEmpty(_displayedText) ? "" : " ") + word;
|
|
StateHasChanged();
|
|
await Task.Delay(40, token); // ~25 words/sec
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Superseded by a newer block — silently drop
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
|
|
Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
_isStreaming = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task HandleActionClick(string action)
|
|
{
|
|
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await QuizState.RequestQuiz(ContextBlockId);
|
|
}
|
|
|
|
if (OnActionTriggered.HasDelegate)
|
|
{
|
|
await OnActionTriggered.InvokeAsync(action);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_streamCts?.Cancel();
|
|
_streamCts?.Dispose();
|
|
}
|
|
}
|