From 0ed89ef5a472de571b462b30e293b7ec2b71f67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Fri, 1 May 2026 20:34:00 +0200 Subject: [PATCH] feat: implement AI-driven text streaming and dynamic knowledge graph generation in AiAssistantBubble --- .../Queries/Graph/GetKnowledgeGraphQuery.cs | 4 +- .../Graph/GetKnowledgeGraphQueryHandler.cs | 37 +++++- .../Molecules/AiAssistantBubble.razor | 108 +++++++++++++++++- 3 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs index d95619a..0ef50f0 100644 --- a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs @@ -2,4 +2,6 @@ using NexusReader.Application.Abstractions.Messaging; namespace NexusReader.Application.Queries.Graph; -public record GetKnowledgeGraphQuery : IQuery; +/// Chapter or page content to extract the graph from. +public record GetKnowledgeGraphQuery(string Text) : IQuery; + diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs index 05f01ff..bfc4ff7 100644 --- a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs @@ -1,15 +1,42 @@ using FluentResults; using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Abstractions.Services; namespace NexusReader.Application.Queries.Graph; internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler { - public Task> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken) - { - var nodes = new List(); - var links = new List(); + private readonly IKnowledgeService _knowledgeService; - return Task.FromResult(Result.Ok(new GraphDataDto { Nodes = nodes, Links = links })); + public GetKnowledgeGraphQueryHandler(IKnowledgeService knowledgeService) + { + _knowledgeService = knowledgeService; + } + + public async Task> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Text)) + return Result.Ok(new GraphDataDto()); + + var result = await _knowledgeService.GetGraphDataAsync(request.Text, cancellationToken); + + if (result.IsFailed) + return Result.Fail(result.Errors); + + var graph = result.Value.Graph; + + if (graph is null) + return Result.Ok(new GraphDataDto()); + + var nodes = graph.Nodes + .Select(n => new GraphNodeDto(n.Id, n.Label, n.Group)) + .ToList(); + + var links = graph.Links + .Select(l => new GraphLinkDto(l.Source, l.Target, l.Value)) + .ToList(); + + return Result.Ok(new GraphDataDto { Nodes = nodes, Links = links }); } } + diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor index 37ab954..d5eeb1e 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor @@ -1,36 +1,126 @@ @using NexusReader.UI.Shared.Services +@using NexusReader.Application.DTOs.AI @inject IQuizStateService QuizState +@inject KnowledgeCoordinator Coordinator +@implements IDisposable
- +
E-Czytnik Asystent AI
- @Dialogue - + @if (_isLoading) + { +
+
Analizuję fragment...
+
+ } + else + { + + @_displayedText@(_isStreaming ? "▍" : "") + + } +
- +
-
- @code { [Parameter] public string ContextBlockId { get; set; } = string.Empty; + /// Fallback static dialogue shown when no live AI content is available. [Parameter] public string Dialogue { get; set; } = string.Empty; [Parameter] public List Actions { get; set; } = new(); [Parameter] public EventCallback 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; + + protected override async Task OnParametersSetAsync() + { + // Only re-fetch when the block context actually changes + if (string.IsNullOrEmpty(ContextBlockId) || ContextBlockId == _lastFetchedBlockId) + return; + + _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 + { + _packet = await Coordinator.RequestSummaryAndQuizAsync( + $"[ID: {ContextBlockId}]\n{Dialogue}"); + + 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)) @@ -43,4 +133,10 @@ await OnActionTriggered.InvokeAsync(action); } } + + public void Dispose() + { + _streamCts?.Cancel(); + _streamCts?.Dispose(); + } }