feat: implement AI-driven text streaming and dynamic knowledge graph generation in AiAssistantBubble

This commit is contained in:
2026-05-01 20:34:00 +02:00
parent 0cc25bb412
commit 0ed89ef5a4
3 changed files with 137 additions and 12 deletions
@@ -2,4 +2,6 @@ using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Graph;
public record GetKnowledgeGraphQuery : IQuery<GraphDataDto>;
/// <param name="Text">Chapter or page content to extract the graph from.</param>
public record GetKnowledgeGraphQuery(string Text) : IQuery<GraphDataDto>;
@@ -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<GetKnowledgeGraphQuery, GraphDataDto>
{
public Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
{
var nodes = new List<GraphNodeDto>();
var links = new List<GraphLinkDto>();
private readonly IKnowledgeService _knowledgeService;
return Task.FromResult(Result.Ok(new GraphDataDto { Nodes = nodes, Links = links }));
public GetKnowledgeGraphQueryHandler(IKnowledgeService knowledgeService)
{
_knowledgeService = knowledgeService;
}
public async Task<Result<GraphDataDto>> 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<GraphDataDto>(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 });
}
}
@@ -1,36 +1,126 @@
@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="neon-pulse" />
<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">
<NexusTypography Variant="NexusTypography.TypographyVariant.UI">@Dialogue</NexusTypography>
@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")'>Rozwiąż quiz</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 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;
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();
}
}