feat: implement AI-driven text streaming and dynamic knowledge graph generation in AiAssistantBubble
This commit is contained in:
@@ -2,4 +2,6 @@ using NexusReader.Application.Abstractions.Messaging;
|
|||||||
|
|
||||||
namespace NexusReader.Application.Queries.Graph;
|
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 FluentResults;
|
||||||
using NexusReader.Application.Abstractions.Messaging;
|
using NexusReader.Application.Abstractions.Messaging;
|
||||||
|
using NexusReader.Application.Abstractions.Services;
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Graph;
|
namespace NexusReader.Application.Queries.Graph;
|
||||||
|
|
||||||
internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto>
|
internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledgeGraphQuery, GraphDataDto>
|
||||||
{
|
{
|
||||||
public Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
|
private readonly IKnowledgeService _knowledgeService;
|
||||||
{
|
|
||||||
var nodes = new List<GraphNodeDto>();
|
|
||||||
var links = new List<GraphLinkDto>();
|
|
||||||
|
|
||||||
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.UI.Shared.Services
|
||||||
|
@using NexusReader.Application.DTOs.AI
|
||||||
@inject IQuizStateService QuizState
|
@inject IQuizStateService QuizState
|
||||||
|
@inject KnowledgeCoordinator Coordinator
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="ai-bubble-container">
|
<div class="ai-bubble-container">
|
||||||
<div class="ai-bubble">
|
<div class="ai-bubble">
|
||||||
<div class="ai-avatar">
|
<div class="ai-avatar">
|
||||||
<div class="avatar-ring"></div>
|
<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">
|
<div class="avatar-label">
|
||||||
<span class="name">E-Czytnik</span>
|
<span class="name">E-Czytnik</span>
|
||||||
<span class="role">Asystent AI</span>
|
<span class="role">Asystent AI</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-content">
|
<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">
|
<div class="ai-actions">
|
||||||
<button class="action-btn ghost" @onclick='() => HandleActionClick("more")'>Pokaż więcej informacji</button>
|
<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>
|
</div>
|
||||||
<div class="bubble-pointer"></div>
|
<div class="bubble-pointer"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
[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 string Dialogue { get; set; } = string.Empty;
|
||||||
[Parameter] public List<string> Actions { get; set; } = new();
|
[Parameter] public List<string> Actions { get; set; } = new();
|
||||||
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
|
[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)
|
private async Task HandleActionClick(string action)
|
||||||
{
|
{
|
||||||
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -43,4 +133,10 @@
|
|||||||
await OnActionTriggered.InvokeAsync(action);
|
await OnActionTriggered.InvokeAsync(action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_streamCts?.Cancel();
|
||||||
|
_streamCts?.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user