feat: implement dynamic knowledge graph updates and state management services
This commit is contained in:
@@ -6,4 +6,5 @@ namespace NexusReader.Application.Abstractions.Services;
|
||||
public interface IKnowledgeService
|
||||
{
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default);
|
||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ public record QuizQuestion(
|
||||
[property: JsonPropertyName("correct_index")] int CorrectIndex
|
||||
);
|
||||
|
||||
public record KnowledgePacket(
|
||||
[property: JsonPropertyName("concepts")] List<KeyConcept> Concepts,
|
||||
[property: JsonPropertyName("quizzes")] List<QuizQuestion> Quizzes
|
||||
);
|
||||
public record KnowledgePacket
|
||||
{
|
||||
[JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new();
|
||||
[JsonPropertyName("quizzes")] public List<QuizQuestion> Quizzes { get; init; } = new();
|
||||
[JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; }
|
||||
}
|
||||
|
||||
@@ -7,24 +7,9 @@ internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler<GetKnowledge
|
||||
{
|
||||
public Task<Result<GraphDataDto>> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var nodes = new List<GraphNodeDto>
|
||||
{
|
||||
new("renesans-intro", "Renesans", "Concept"),
|
||||
new("florencja", "Florencja", "Location"),
|
||||
new("medyceusze", "Medyceusze", "Entity"),
|
||||
new("da-vinci-ai", "Leonardo da Vinci", "Person"),
|
||||
new("humanizm", "Humanizm", "Concept")
|
||||
};
|
||||
var nodes = new List<GraphNodeDto>();
|
||||
var links = new List<GraphLinkDto>();
|
||||
|
||||
var links = new List<GraphLinkDto>
|
||||
{
|
||||
new("renesans-intro", "florencja", 1),
|
||||
new("florencja", "medyceusze", 2),
|
||||
new("medyceusze", "da-vinci-ai", 3),
|
||||
new("renesans-intro", "humanizm", 1),
|
||||
new("da-vinci-ai", "humanizm", 2)
|
||||
};
|
||||
|
||||
return Task.FromResult(Result.Ok(new GraphDataDto(nodes, links)));
|
||||
return Task.FromResult(Result.Ok(new GraphDataDto { Nodes = nodes, Links = links }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,8 @@ namespace NexusReader.Application.Queries.Graph;
|
||||
|
||||
public record GraphNodeDto(string Id, string Label, string Group);
|
||||
public record GraphLinkDto(string Source, string Target, int Value);
|
||||
public record GraphDataDto(List<GraphNodeDto> Nodes, List<GraphLinkDto> Links);
|
||||
public record GraphDataDto
|
||||
{
|
||||
public List<GraphNodeDto> Nodes { get; init; } = new();
|
||||
public List<GraphLinkDto> Links { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -24,9 +24,11 @@ public static class DependencyInjection
|
||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||
|
||||
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||
{
|
||||
// We don't throw here to allow the app to start, but services using AI will fail gracefully
|
||||
Console.WriteLine("[Infrastructure] WARNING: AI API Key is missing or placeholder!");
|
||||
}
|
||||
|
||||
services.AddResiliencePipeline("ai-retry", builder =>
|
||||
|
||||
@@ -41,12 +41,15 @@ public class KnowledgeService : IKnowledgeService
|
||||
return Result.Fail("Input text is empty.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"[KnowledgeService] Starting extraction for text: {text.Substring(0, Math.Min(text.Length, 50))}...");
|
||||
|
||||
// Normalize text to ensure consistent hashing and reduce token noise
|
||||
var normalizedText = ContentHasher.Normalize(text);
|
||||
|
||||
// Phase 4: Request Pre-processing (Token Saving)
|
||||
if (normalizedText.Length > _settings.MaxInputLength)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Error: Input too long ({normalizedText.Length} > {_settings.MaxInputLength})");
|
||||
return Result.Fail($"Input text is too long ({normalizedText.Length} characters after normalization). Max allowed is {_settings.MaxInputLength}.");
|
||||
}
|
||||
|
||||
@@ -62,6 +65,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Cache hit for hash: {hash}");
|
||||
try
|
||||
{
|
||||
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData);
|
||||
@@ -70,18 +74,19 @@ public class KnowledgeService : IKnowledgeService
|
||||
return Result.Ok(packet);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// If deserialization fails, we proceed to call the AI
|
||||
Console.WriteLine($"[KnowledgeService] Cache deserialization error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Call AI Client
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Calling Gemini AI with Model: {_settings.Model}...");
|
||||
var options = new ChatOptions
|
||||
{
|
||||
ResponseFormat = ChatResponseFormat.Json,
|
||||
// ResponseFormat = ChatResponseFormat.Json, // Disabled due to GeminiMappingException in current library version
|
||||
Temperature = (float)_settings.Temperature,
|
||||
MaxOutputTokens = _settings.MaxOutputTokens
|
||||
};
|
||||
@@ -96,19 +101,24 @@ public class KnowledgeService : IKnowledgeService
|
||||
var jsonResponse = response.Text;
|
||||
if (string.IsNullOrWhiteSpace(jsonResponse))
|
||||
{
|
||||
Console.WriteLine("[KnowledgeService] AI returned empty response.");
|
||||
return Result.Fail("AI returned an empty response.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"[KnowledgeService] AI Response received ({jsonResponse.Length} chars).");
|
||||
|
||||
// Cleanup potential markdown if Gemini still adds it despite options
|
||||
jsonResponse = jsonResponse.Replace("```json", "").Replace("```", "").Trim();
|
||||
|
||||
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse);
|
||||
if (knowledgePacket == null)
|
||||
{
|
||||
Console.WriteLine("[KnowledgeService] Failed to deserialize JSON response.");
|
||||
return Result.Fail("Failed to deserialize AI response.");
|
||||
}
|
||||
|
||||
// 3. Save to Cache
|
||||
Console.WriteLine("[KnowledgeService] Saving result to cache...");
|
||||
var cacheEntry = new SemanticKnowledgeCache
|
||||
{
|
||||
ContentHash = hash,
|
||||
@@ -118,7 +128,6 @@ public class KnowledgeService : IKnowledgeService
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Handle potential race condition if multiple requests for same text arrive
|
||||
if (cached == null)
|
||||
{
|
||||
_dbContext.SemanticKnowledgeCache.Add(cacheEntry);
|
||||
@@ -130,12 +139,34 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
Console.WriteLine("[KnowledgeService] Extraction successful.");
|
||||
|
||||
return Result.Ok(knowledgePacket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] CRITICAL ERROR: {ex.GetType().Name}: {ex.Message}");
|
||||
if (ex.InnerException != null)
|
||||
Console.WriteLine($"[KnowledgeService] Inner Error: {ex.InnerException.Message}");
|
||||
|
||||
return Result.Fail(new Error("Failed to extract knowledge from AI").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine("[KnowledgeService] Clearing SemanticKnowledgeCache...");
|
||||
_dbContext.SemanticKnowledgeCache.RemoveRange(_dbContext.SemanticKnowledgeCache);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
Console.WriteLine("[KnowledgeService] Cache cleared successfully.");
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Error clearing cache: {ex.Message}");
|
||||
return Result.Fail($"Failed to clear cache: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ namespace NexusReader.Infrastructure.Services;
|
||||
public static class PromptRegistry
|
||||
{
|
||||
public const string KnowledgeExtractionSystemPrompt =
|
||||
"You are an expert educator. Analyze the provided text to extract key concepts and generate relevant quizzes. " +
|
||||
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
|
||||
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
|
||||
"Schema: { \"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }.";
|
||||
"Schema: { " +
|
||||
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
|
||||
"\"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ], " +
|
||||
"\"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } " +
|
||||
"}.";
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
case "target":
|
||||
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
|
||||
break;
|
||||
case "trash":
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
|
||||
break;
|
||||
default:
|
||||
<!-- Fallback circle -->
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -31,13 +31,10 @@
|
||||
[Parameter] public List<string> Actions { get; set; } = new();
|
||||
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
|
||||
|
||||
private bool _isQuizMode = false;
|
||||
|
||||
private async Task HandleActionClick(string action)
|
||||
{
|
||||
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_isQuizMode = true;
|
||||
QuizState.RequestQuiz(ContextBlockId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
|
||||
<aside class="intelligence-toolbar">
|
||||
<div class="toolbar-top">
|
||||
@@ -21,6 +23,9 @@
|
||||
<button class="toolbar-item" title="Search">
|
||||
<NexusIcon Name="search" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item danger" @onclick="HandleClearCache" title="Clear AI Cache">
|
||||
<NexusIcon Name="trash" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-bottom">
|
||||
@@ -40,6 +45,17 @@
|
||||
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||
}
|
||||
|
||||
private async Task HandleClearCache()
|
||||
{
|
||||
// For now, a simple console log confirm or just do it
|
||||
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
|
||||
var result = await KnowledgeService.ClearCacheAsync();
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||
|
||||
@@ -65,3 +65,8 @@
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toolbar-item.danger:hover {
|
||||
color: #ff4d4d;
|
||||
background: rgba(255, 77, 77, 0.1);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@inject IMediator Mediator
|
||||
@inject IPlatformService PlatformService
|
||||
@inject IQuizStateService QuizService
|
||||
|
||||
<div class="knowledge-check">
|
||||
<div class="quiz-header">
|
||||
@@ -11,14 +12,14 @@
|
||||
<button class="expand-btn">⌵</button>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
@if (QuizService.IsHydrating)
|
||||
{
|
||||
<div class="loading-state">Pobieranie pytań...</div>
|
||||
<div class="loading-state shimmer">Skanowanie wiedzy przez AI...</div>
|
||||
}
|
||||
else if (_quiz != null)
|
||||
else if (QuizService.CurrentQuiz != null)
|
||||
{
|
||||
<div class="quiz-body">
|
||||
@foreach (var question in _quiz.Questions)
|
||||
@foreach (var question in QuizService.CurrentQuiz.Questions)
|
||||
{
|
||||
<div class="question-container">
|
||||
<p class="question-text">@question.Question</p>
|
||||
@@ -50,21 +51,16 @@
|
||||
@code {
|
||||
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
||||
|
||||
private bool _isLoading = true;
|
||||
private QuizDto? _quiz;
|
||||
|
||||
private Dictionary<QuizQuestionDto, (int SelectedIndex, bool IsCorrect)> _states = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_isLoading = true;
|
||||
var query = new GetQuizQuestionsQuery(ContextBlockId);
|
||||
var result = await Mediator.Send(query);
|
||||
|
||||
if (result.IsSuccess)
|
||||
_quiz = result.Value;
|
||||
|
||||
_isLoading = false;
|
||||
QuizService.OnQuizUpdated += () => InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
QuizService.OnQuizUpdated -= StateHasChanged;
|
||||
}
|
||||
|
||||
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
|
||||
@@ -89,7 +85,7 @@
|
||||
|
||||
private bool AllQuestionsAnswered()
|
||||
{
|
||||
return _quiz != null && _states.Count == _quiz.Questions.Count;
|
||||
return QuizService.CurrentQuiz != null && _states.Count == QuizService.CurrentQuiz.Questions.Count;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -106,3 +106,18 @@
|
||||
border-color: #ff4444 !important;
|
||||
background: rgba(255, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
.loading-state.shimmer {
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--nexus-neon);
|
||||
text-shadow: 0 0 10px var(--nexus-neon);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@inject IMediator Mediator
|
||||
@inject IJSRuntime JS
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IKnowledgeGraphService GraphService
|
||||
|
||||
<div class="knowledge-graph-container" id="@ContainerId">
|
||||
@if (GraphData == null)
|
||||
@@ -37,6 +38,21 @@
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += HandleFocusSimulation;
|
||||
GraphService.OnGraphUpdated += HandleGraphUpdate;
|
||||
GraphService.OnActiveNodeChanged += HandleActiveNodeChange;
|
||||
}
|
||||
|
||||
private async void HandleGraphUpdate()
|
||||
{
|
||||
if (_module == null) return;
|
||||
await _module.InvokeVoidAsync("updateData", GraphService.CurrentGraphData);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async void HandleActiveNodeChange(string nodeId)
|
||||
{
|
||||
if (_module == null) return;
|
||||
await _module.InvokeVoidAsync("setActiveNode", nodeId);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@inject IThemeService ThemeService
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IReaderNavigationService NavigationService
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
|
||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||
@if (ViewModel == null)
|
||||
@@ -59,6 +60,31 @@
|
||||
{
|
||||
await LoadChapterAsync(NavigationService.CurrentChapterIndex);
|
||||
StateHasChanged();
|
||||
await InitializeObserverAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await InitializeObserverAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeObserverAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js");
|
||||
await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void HandleBlockReached(string blockId, string content)
|
||||
{
|
||||
Coordinator.OnBlockReached(blockId, content);
|
||||
}
|
||||
|
||||
private async Task LoadChapterAsync(int index)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@inject IPlatformService PlatformService
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IQuizStateService QuizService
|
||||
@implements IDisposable
|
||||
|
||||
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
|
||||
<div class="reader-pane">
|
||||
@@ -18,7 +20,7 @@
|
||||
<IntelligenceToolbar />
|
||||
<div class="intelligence-content">
|
||||
<div class="intelligence-header">
|
||||
<NexusIcon Name="robot" Size="20" Class="neon-glow" />
|
||||
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
|
||||
<span>Asystent AI i Interaktywna Mapa</span>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
@@ -43,6 +45,7 @@
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged += StateHasChanged;
|
||||
QuizService.OnQuizUpdated += StateHasChanged;
|
||||
|
||||
var context = PlatformService.GetDeviceContext();
|
||||
if (context.IsSuccess)
|
||||
@@ -58,6 +61,7 @@
|
||||
public void Dispose()
|
||||
{
|
||||
FocusMode.OnFocusModeChanged -= StateHasChanged;
|
||||
QuizService.OnQuizUpdated -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,4 +120,15 @@ main {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.quiz-available {
|
||||
animation: quiz-pulse 1.5s infinite;
|
||||
color: var(--nexus-neon) !important;
|
||||
}
|
||||
|
||||
@keyframes quiz-pulse {
|
||||
0% { filter: drop-shadow(0 0 2px var(--nexus-neon)); transform: scale(1); }
|
||||
50% { filter: drop-shadow(0 0 10px var(--nexus-neon)); transform: scale(1.1); }
|
||||
100% { filter: drop-shadow(0 0 2px var(--nexus-neon)); transform: scale(1); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using NexusReader.Application.Queries.Graph;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IKnowledgeGraphService
|
||||
{
|
||||
GraphDataDto? CurrentGraphData { get; }
|
||||
string? ActiveNodeId { get; }
|
||||
|
||||
event Action? OnGraphUpdated;
|
||||
event Action<string>? OnActiveNodeChanged;
|
||||
|
||||
void UpdateGraph(GraphDataDto newData);
|
||||
void SetActiveNode(string nodeId);
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
using NexusReader.Application.Queries.Quiz;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IQuizStateService
|
||||
{
|
||||
string? CurrentQuizBlockId { get; }
|
||||
QuizDto? CurrentQuiz { get; }
|
||||
bool IsHydrating { get; }
|
||||
bool HasNewQuiz { get; }
|
||||
|
||||
event Action<string>? OnQuizRequested;
|
||||
event Action? OnQuizUpdated;
|
||||
|
||||
void RequestQuiz(string blockId);
|
||||
void SetQuiz(string blockId, QuizDto quiz);
|
||||
void SetHydrating(bool hydrating);
|
||||
void MarkQuizAsSeen();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Graph;
|
||||
using NexusReader.Application.Queries.Quiz;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class KnowledgeCoordinator : IDisposable
|
||||
{
|
||||
private readonly IKnowledgeService _knowledgeService;
|
||||
private readonly IKnowledgeGraphService _graphService;
|
||||
private readonly IQuizStateService _quizService;
|
||||
private readonly IPlatformService _platformService;
|
||||
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
|
||||
public KnowledgeCoordinator(
|
||||
IKnowledgeService knowledgeService,
|
||||
IKnowledgeGraphService graphService,
|
||||
IQuizStateService quizService,
|
||||
IPlatformService platformService)
|
||||
{
|
||||
_knowledgeService = knowledgeService;
|
||||
_graphService = graphService;
|
||||
_quizService = quizService;
|
||||
_platformService = platformService;
|
||||
}
|
||||
|
||||
public void OnBlockReached(string blockId, string content)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeCoordinator] Block reached: {blockId}");
|
||||
|
||||
// 1. Skip extraction for the title page (usually the first block or contains 'title')
|
||||
if (blockId.Equals("seg-0", StringComparison.OrdinalIgnoreCase) ||
|
||||
blockId.Contains("title", StringComparison.OrdinalIgnoreCase) ||
|
||||
content.Length < 50) // Title pages are usually short
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeCoordinator] Skipping extraction for title page/short block: {blockId}");
|
||||
_graphService.SetActiveNode(blockId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Update active node immediately for "TU JESTEŚ" logic
|
||||
_graphService.SetActiveNode(blockId);
|
||||
|
||||
// 3. Debounce the AI extraction to prevent spamming while scrolling
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
var token = _debounceCts.Token;
|
||||
|
||||
_ = DebounceAndExtractAsync(blockId, content, token);
|
||||
}
|
||||
|
||||
private async Task DebounceAndExtractAsync(string blockId, string content, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(1000, token);
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
Console.WriteLine($"[KnowledgeCoordinator] Triggering extraction for block: {blockId}");
|
||||
await ProcessKnowledgeExtractionAsync(blockId, content, token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeCoordinator] Unexpected error in task: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessKnowledgeExtractionAsync(string blockId, string content, CancellationToken ct)
|
||||
{
|
||||
_quizService.SetHydrating(true);
|
||||
|
||||
var result = await _knowledgeService.GetKnowledgeAsync(content, ct);
|
||||
|
||||
if (result.IsSuccess && !ct.IsCancellationRequested)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeCoordinator] Extraction success for block: {blockId}. Updating state...");
|
||||
var packet = result.Value;
|
||||
|
||||
// Update Quiz State
|
||||
var quizQuestions = packet.Quizzes
|
||||
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
|
||||
.ToList();
|
||||
_quizService.SetQuiz(blockId, new QuizDto(quizQuestions));
|
||||
|
||||
// Update Graph State
|
||||
GraphDataDto graphData;
|
||||
if (packet.Graph != null && packet.Graph.Nodes != null && packet.Graph.Nodes.Any())
|
||||
{
|
||||
// Use AI-generated graph
|
||||
graphData = packet.Graph;
|
||||
|
||||
// Ensure current block is linked to the first concept or added if missing
|
||||
if (!graphData.Nodes.Any(n => n.Id == blockId))
|
||||
{
|
||||
graphData.Nodes.Add(new GraphNodeDto(blockId, "TU JESTEŚ", "current"));
|
||||
if (graphData.Nodes.Count > 1)
|
||||
{
|
||||
graphData.Links.Add(new GraphLinkDto(blockId, graphData.Nodes[0].Id, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: Transform Concepts to GraphData if AI didn't provide a graph
|
||||
var nodes = packet.Concepts
|
||||
.Select(c => new GraphNodeDto(c.Title.ToLowerInvariant(), c.Title, "concept"))
|
||||
.ToList();
|
||||
|
||||
nodes.Add(new GraphNodeDto(blockId, "TU JESTEŚ", "current"));
|
||||
|
||||
var links = packet.Concepts
|
||||
.Select(c => new GraphLinkDto(blockId, c.Title.ToLowerInvariant(), 1))
|
||||
.ToList();
|
||||
|
||||
graphData = new GraphDataDto { Nodes = nodes, Links = links };
|
||||
}
|
||||
|
||||
_graphService.UpdateGraph(graphData);
|
||||
|
||||
// Visual/Haptic Feedback
|
||||
await _platformService.VibrateSuccessAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeCoordinator] Extraction failed or returned empty for block: {blockId}. Error: {result.Errors.FirstOrDefault()?.Message}");
|
||||
}
|
||||
_quizService.SetHydrating(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using NexusReader.Application.Queries.Graph;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class KnowledgeGraphService : IKnowledgeGraphService
|
||||
{
|
||||
public GraphDataDto? CurrentGraphData { get; private set; }
|
||||
public string? ActiveNodeId { get; private set; }
|
||||
|
||||
public event Action? OnGraphUpdated;
|
||||
public event Action<string>? OnActiveNodeChanged;
|
||||
|
||||
public void UpdateGraph(GraphDataDto newData)
|
||||
{
|
||||
CurrentGraphData = newData;
|
||||
OnGraphUpdated?.Invoke();
|
||||
}
|
||||
|
||||
public void SetActiveNode(string nodeId)
|
||||
{
|
||||
if (ActiveNodeId == nodeId) return;
|
||||
ActiveNodeId = nodeId;
|
||||
OnActiveNodeChanged?.Invoke(nodeId);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,42 @@
|
||||
using NexusReader.Application.Queries.Quiz;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class QuizStateService : IQuizStateService
|
||||
{
|
||||
public string? CurrentQuizBlockId { get; private set; }
|
||||
public QuizDto? CurrentQuiz { get; private set; }
|
||||
public bool IsHydrating { get; private set; }
|
||||
public bool HasNewQuiz { get; private set; }
|
||||
|
||||
public event Action<string>? OnQuizRequested;
|
||||
public event Action? OnQuizUpdated;
|
||||
|
||||
public void RequestQuiz(string blockId)
|
||||
{
|
||||
CurrentQuizBlockId = blockId;
|
||||
OnQuizRequested?.Invoke(blockId);
|
||||
}
|
||||
|
||||
public void SetQuiz(string blockId, QuizDto quiz)
|
||||
{
|
||||
CurrentQuizBlockId = blockId;
|
||||
CurrentQuiz = quiz;
|
||||
IsHydrating = false;
|
||||
HasNewQuiz = true;
|
||||
OnQuizUpdated?.Invoke();
|
||||
}
|
||||
|
||||
public void SetHydrating(bool hydrating)
|
||||
{
|
||||
IsHydrating = hydrating;
|
||||
OnQuizUpdated?.Invoke();
|
||||
}
|
||||
|
||||
public void MarkQuizAsSeen()
|
||||
{
|
||||
if (!HasNewQuiz) return;
|
||||
HasNewQuiz = false;
|
||||
OnQuizUpdated?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ let simulation;
|
||||
let zoomBehavior;
|
||||
let svgElement;
|
||||
|
||||
let node, link, rootGroup, badge, width, height, currentDotNetHelper;
|
||||
|
||||
export function mount(containerId, data, dotNetHelper) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth || 400;
|
||||
const height = container.clientHeight || 400;
|
||||
currentDotNetHelper = dotNetHelper;
|
||||
width = container.clientWidth || 400;
|
||||
height = container.clientHeight || 400;
|
||||
|
||||
// Create SVG
|
||||
svgElement = d3.select(container).append("svg")
|
||||
@@ -28,12 +31,17 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
|
||||
|
||||
// Root Group for Zoom
|
||||
const rootGroup = svgElement.append("g").attr("class", "zoom-containment");
|
||||
rootGroup = svgElement.append("g").attr("class", "zoom-containment");
|
||||
|
||||
// Container groups for links and nodes to keep order (links below nodes)
|
||||
rootGroup.append("g").attr("class", "links-layer");
|
||||
rootGroup.append("g").attr("class", "nodes-layer");
|
||||
|
||||
// Badge Element (TU JESTEŚ)
|
||||
const badge = rootGroup.append("g")
|
||||
badge = rootGroup.append("g")
|
||||
.attr("class", "active-badge")
|
||||
.style("display", "none");
|
||||
.style("display", "none")
|
||||
.style("pointer-events", "none");
|
||||
|
||||
badge.append("rect")
|
||||
.attr("x", -35)
|
||||
@@ -53,92 +61,120 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
|
||||
// Attach Zoom Behavior
|
||||
zoomBehavior = d3.zoom()
|
||||
.scaleExtent([0.5, 4])
|
||||
.scaleExtent([0.3, 4])
|
||||
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
|
||||
|
||||
// Apply zoom but disable wheel interaction
|
||||
svgElement.call(zoomBehavior)
|
||||
.on("wheel.zoom", null);
|
||||
svgElement.call(zoomBehavior).on("wheel.zoom", null);
|
||||
|
||||
|
||||
// Subtle Link Distance & Charge
|
||||
simulation = d3.forceSimulation(data.nodes)
|
||||
.force("link", d3.forceLink(data.links).id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-300))
|
||||
simulation = d3.forceSimulation()
|
||||
.force("link", d3.forceLink().id(d => d.id).distance(120))
|
||||
.force("charge", d3.forceManyBody().strength(-400))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collide", d3.forceCollide().radius(40));
|
||||
|
||||
// Links
|
||||
const link = rootGroup.append("g")
|
||||
.selectAll("path")
|
||||
.data(data.links)
|
||||
.join("path")
|
||||
.attr("stroke", "rgba(255,255,255,0.1)")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 1.5);
|
||||
|
||||
// Nodes
|
||||
const node = rootGroup.append("g")
|
||||
.selectAll("g")
|
||||
.data(data.nodes)
|
||||
.join("g")
|
||||
.style("cursor", "pointer")
|
||||
.on("click", (e, d) => {
|
||||
// Remove active state from all, add to clicked
|
||||
node.selectAll(".node-pill").classed("nexus-node-active", false);
|
||||
d3.select(e.currentTarget).select(".node-pill").classed("nexus-node-active", true);
|
||||
|
||||
// Show badge
|
||||
badge.style("display", "block").datum(d);
|
||||
|
||||
dotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
|
||||
})
|
||||
.call(drag(simulation));
|
||||
|
||||
// Outer glow for nodes
|
||||
node.append("circle")
|
||||
.attr("r", 30)
|
||||
.attr("fill", "url(#nebulaGlow)")
|
||||
.attr("opacity", d => d.id === 'root' ? 0.6 : 0.2);
|
||||
|
||||
// Pill shape
|
||||
node.append("rect")
|
||||
.attr("class", "node-pill")
|
||||
.attr("x", d => -(d.label.length * 4 + 10))
|
||||
.attr("y", -12)
|
||||
.attr("width", d => d.label.length * 8 + 20)
|
||||
.attr("height", 24)
|
||||
.attr("rx", 12)
|
||||
.attr("fill", "rgba(30, 30, 30, 0.8)")
|
||||
.attr("stroke", "rgba(255, 255, 255, 0.1)")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Labels
|
||||
node.append("text")
|
||||
.text(d => d.label)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 4)
|
||||
.attr("fill", "#ccc")
|
||||
.attr("font-family", "var(--nexus-font-sans)")
|
||||
.attr("font-size", "0.8rem");
|
||||
.force("collide", d3.forceCollide().radius(50));
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link.attr("d", d => {
|
||||
const dx = d.target.x - d.source.x;
|
||||
const dy = d.target.y - d.source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
|
||||
});
|
||||
if (link) {
|
||||
link.attr("d", d => {
|
||||
const dx = d.target.x - d.source.x;
|
||||
const dy = d.target.y - d.source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
|
||||
});
|
||||
}
|
||||
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
if (node) {
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
|
||||
const activeData = badge.datum();
|
||||
if (activeData) {
|
||||
badge.attr("transform", `translate(${activeData.x},${activeData.y})`);
|
||||
if (badge && badge.style("display") !== "none") {
|
||||
const activeData = badge.datum();
|
||||
if (activeData) {
|
||||
badge.attr("transform", `translate(${activeData.x},${activeData.y})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateData(data);
|
||||
}
|
||||
|
||||
export function updateData(data) {
|
||||
if (!simulation || !rootGroup) return;
|
||||
|
||||
// Keep existing node positions if they match by ID
|
||||
const oldNodes = new Map(simulation.nodes().map(d => [d.id, d]));
|
||||
data.nodes.forEach(d => {
|
||||
if (oldNodes.has(d.id)) {
|
||||
const old = oldNodes.get(d.id);
|
||||
d.x = old.x;
|
||||
d.y = old.y;
|
||||
d.vx = old.vx;
|
||||
d.vy = old.vy;
|
||||
}
|
||||
});
|
||||
|
||||
// Update Links
|
||||
link = rootGroup.select(".links-layer")
|
||||
.selectAll("path")
|
||||
.data(data.links, d => d.source + "-" + d.target)
|
||||
.join(
|
||||
enter => enter.append("path")
|
||||
.attr("stroke", "rgba(255,255,255,0.05)")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 1.5)
|
||||
.call(e => e.transition().duration(500).attr("stroke", "rgba(255,255,255,0.1)")),
|
||||
update => update,
|
||||
exit => exit.remove()
|
||||
);
|
||||
|
||||
// Update Nodes
|
||||
node = rootGroup.select(".nodes-layer")
|
||||
.selectAll("g.node-group")
|
||||
.data(data.nodes, d => d.id)
|
||||
.join(
|
||||
enter => {
|
||||
const g = enter.append("g")
|
||||
.attr("class", "node-group")
|
||||
.style("cursor", "pointer")
|
||||
.on("click", (e, d) => {
|
||||
currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
|
||||
setActiveNode(d.id);
|
||||
})
|
||||
.call(drag(simulation));
|
||||
|
||||
g.append("circle")
|
||||
.attr("r", 30)
|
||||
.attr("fill", "url(#nebulaGlow)")
|
||||
.attr("opacity", 0)
|
||||
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
|
||||
|
||||
g.append("rect")
|
||||
.attr("class", "node-pill")
|
||||
.attr("x", d => -(d.label.length * 4 + 10))
|
||||
.attr("y", -12)
|
||||
.attr("width", d => d.label.length * 8 + 20)
|
||||
.attr("height", 24)
|
||||
.attr("rx", 12)
|
||||
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
||||
.attr("stroke", "rgba(255, 255, 255, 0.1)")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
g.append("text")
|
||||
.text(d => d.label)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 4)
|
||||
.attr("fill", "#ccc")
|
||||
.attr("font-size", "0.8rem");
|
||||
|
||||
return g;
|
||||
},
|
||||
update => update,
|
||||
exit => exit.remove()
|
||||
);
|
||||
|
||||
simulation.nodes(data.nodes);
|
||||
simulation.force("link").links(data.links);
|
||||
simulation.alpha(0.5).restart();
|
||||
}
|
||||
|
||||
function drag(simulation) {
|
||||
function dragstarted(event) {
|
||||
@@ -161,6 +197,29 @@ function drag(simulation) {
|
||||
.on("end", dragended);
|
||||
}
|
||||
|
||||
export function setActiveNode(nodeId) {
|
||||
if (!svgElement || !node) return;
|
||||
|
||||
const targetNode = node.filter(d => d.id === nodeId);
|
||||
if (targetNode.empty()) return;
|
||||
|
||||
const d = targetNode.datum();
|
||||
|
||||
// Reset all active classes
|
||||
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
|
||||
targetNode.select(".node-pill").classed("nexus-node-active", true);
|
||||
|
||||
// Position badge
|
||||
badge.style("display", "block").datum(d);
|
||||
badge.attr("transform", `translate(${d.x},${d.y})`);
|
||||
|
||||
// Smooth transition
|
||||
svgElement.transition().duration(1000).call(
|
||||
zoomBehavior.transform,
|
||||
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y)
|
||||
);
|
||||
}
|
||||
|
||||
export function unmount(containerId) {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function initObserver(dotNetHelper, containerSelector, itemSelector) {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.6 // 60% of the block must be visible
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const id = entry.target.id;
|
||||
const content = entry.target.innerText;
|
||||
dotNetHelper.invokeMethodAsync('HandleBlockReached', id, content);
|
||||
}
|
||||
});
|
||||
}, options);
|
||||
|
||||
const items = document.querySelectorAll(itemSelector);
|
||||
items.forEach(item => observer.observe(item));
|
||||
|
||||
return observer;
|
||||
}
|
||||
@@ -13,6 +13,9 @@ builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net.Http.Json;
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
|
||||
namespace NexusReader.Web.Client.Services;
|
||||
|
||||
public class WasmKnowledgeService : IKnowledgeService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public WasmKnowledgeService(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"[WasmKnowledgeService] Calling API for extraction (Text length: {text.Length})...");
|
||||
var response = await _httpClient.PostAsJsonAsync("/api/knowledge", new { text }, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine("[WasmKnowledgeService] API Response success. Deserializing...");
|
||||
var packet = await response.Content.ReadFromJsonAsync<KnowledgePacket>(cancellationToken: cancellationToken);
|
||||
return packet != null ? Result.Ok(packet) : Result.Fail("Failed to deserialize knowledge packet.");
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
Console.WriteLine($"[WasmKnowledgeService] API Error ({response.StatusCode}): {errorBody}");
|
||||
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WasmKnowledgeService] Exception: {ex.Message}");
|
||||
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine("[WasmKnowledgeService] Requesting cache clear...");
|
||||
var response = await _httpClient.DeleteAsync("/api/knowledge", cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine("[WasmKnowledgeService] Cache cleared successfully.");
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
<UserSecretsId>154e0538-f82d-4ec0-81e7-c1ff39d6d919</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Web.Client\NexusReader.Web.Client.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -24,12 +24,21 @@ builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Ensure Database is initialized
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<NexusReader.Infrastructure.Persistence.AppDbContext>();
|
||||
dbContext.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -60,9 +69,29 @@ app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
|
||||
return Results.BadRequest(errorMsg);
|
||||
});
|
||||
|
||||
app.MapPost("/api/knowledge", async (KnowledgeRequest request, IKnowledgeService knowledgeService) =>
|
||||
{
|
||||
var result = await knowledgeService.GetKnowledgeAsync(request.Text);
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
|
||||
var errorMsg = result.Errors.FirstOrDefault()?.Message ?? "Unknown server error";
|
||||
return Results.BadRequest(errorMsg);
|
||||
});
|
||||
|
||||
app.MapDelete("/api/knowledge", async (IKnowledgeService knowledgeService) =>
|
||||
{
|
||||
var result = await knowledgeService.ClearCacheAsync();
|
||||
if (result.IsSuccess) return Results.Ok();
|
||||
|
||||
var errorMsg = result.Errors.FirstOrDefault()?.Message ?? "Unknown server error";
|
||||
return Results.BadRequest(errorMsg);
|
||||
});
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(NexusReader.UI.Shared._Imports).Assembly);
|
||||
.AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly);
|
||||
|
||||
app.Run();
|
||||
|
||||
public record KnowledgeRequest(string Text);
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"Ai": {
|
||||
"Google": {
|
||||
"ApiKey": "PLACEHOLDER",
|
||||
"Model": "gemini-1.5-flash"
|
||||
"Model": "gemini-2.5-flash-lite",
|
||||
"MaxOutputTokens": 4096
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user