feat(recommendations): implement contextual recommendation engine #76

Merged
mjasin merged 5 commits from feature/global-intelligence-qa into develop 2026-06-06 13:38:49 +00:00
15 changed files with 932 additions and 318 deletions
Showing only changes of commit faf6ec826e - Show all commits
+2 -1
View File
@@ -56,7 +56,7 @@ public static class MauiProgram
builder.Services.AddTransient<MobileAuthenticationHeaderHandler>();
builder.Services.AddHttpClient("NexusAPI", client =>
{
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000";
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5104";
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<MobileAuthenticationHeaderHandler>();
@@ -74,6 +74,7 @@ public static class MauiProgram
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddScoped<IIdentityService, IdentityService>();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"ApiSettings": {
"BaseUrl": "https://localhost:5000"
"BaseUrl": "http://localhost:5104"
},
"Serilog": {
"Using": [
@@ -0,0 +1,244 @@
@using NexusReader.UI.Shared.Models
@using NexusReader.UI.Shared.Services
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.DTOs.User
@using System.Net.Http.Json
@inject HttpClient Http
@inject ILibraryStateService LibraryStateService
@inject NavigationManager NavigationManager
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
<div class="message-avatar">
@if (Message.Sender == "User")
Review

Add tabindex="0" to the root div for keyboard focus and ensure focus is set after rendering.

Add tabindex="0" to the root div for keyboard focus and ensure focus is set after rendering.
{
<i class="bi bi-person-fill"></i>
}
else
{
<i class="bi bi-robot"></i>
}
</div>
<div class="message-bubble @GetBubbleClass()">
<div class="message-header">
<span class="sender-name">@Message.Sender</span>
<span class="message-time">@Message.Timestamp.ToString("HH:mm")</span>
</div>
<div class="message-content">
@if (Message.Sender == "User")
{
<p>@Message.Text</p>
}
else
{
@if (Message.IsPaywalled && !_isUnlocked)
{
<div class="clear-teaser">
@foreach (var segment in ParseSegments(Message.ClearText))
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
<div class="teaser-blur">
@foreach (var segment in ParseSegments(Message.BlurredTeaserText))
{
Review

Verify color contrast for premium upsell badge meets WCAG AA; consider using CSS variables for theme colors.

Verify color contrast for premium upsell badge meets WCAG AA; consider using CSS variables for theme colors.
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
Review

Ensure component CSS uses custom properties to respect dark mode.

Ensure component CSS uses custom properties to respect dark mode.
}
</div>
<div class="upsell-card">
<div class="upsell-header">
<span class="upsell-icon">🔒</span>
<h4>Zablokowano pełną odpowiedź (Paywall)</h4>
</div>
<p class="upsell-text">
Powyższy fragment wiedzy pochodzi z materiału:
<strong>'@(string.IsNullOrEmpty(Message.SourceBookTitle) ? "Architektura .NET 10 i Ekosystem Blazor" : Message.SourceBookTitle)'</strong>.
Ta pozycja nie znajduje się w Twojej bibliotece (/my-books). Aby uzyskać dostęp do pełnych instrukcji technicznych oraz gotowych kodów C#, odblokuj ten materiał w katalogu.
</p>
<div class="upsell-actions">
@if (_isSimulatingPayment)
{
<button class="btn-upsell btn-primary loading" disabled>
<div class="payment-spinner"></div>
PRZETWARZANIE PŁATNOŚCI...
</button>
}
else
{
<button class="btn-upsell btn-primary" @onclick="HandlePurchase">
KUP PUBLIKACJĘ (29 PLN)
</button>
}
<a href="/catalog" target="_blank" class="btn-upsell btn-secondary">
Zobacz szczegóły w Katalogu
</a>
</div>
</div>
}
else
{
<div class="full-response">
@foreach (var segment in Message.Segments)
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
@if (_showSuccessBanner)
{
<div class="success-unlock-banner">
<span class="success-icon">✓</span>
<span>Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.</span>
</div>
}
}
}
</div>
</div>
</div>
@code {
[Parameter] public ChatMessage Message { get; set; } = default!;
[Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; }
private string GetBubbleClass()
{
if (Message.Sender == "User") return "user-bubble";
return Message.IsPaywalled && !_isUnlocked ? "ai-bubble paywalled-bubble" : "ai-bubble";
}
private bool _isUnlocked = false;
private bool _isSimulatingPayment = false;
private bool _showSuccessBanner = false;
private async Task HandlePurchase()
{
if (_isSimulatingPayment) return;
_isSimulatingPayment = true;
StateHasChanged();
// Simulate payment gateway delay (1.5 seconds)
await Task.Delay(1500);
try
{
var bookTitle = string.IsNullOrEmpty(Message.SourceBookTitle)
? "Architektura .NET 10 i Ekosystem Blazor"
: Message.SourceBookTitle;
// Call POST endpoint to persist the purchase
var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle });
if (response.IsSuccessStatusCode)
{
_isUnlocked = true;
Message.IsPaywalled = false;
_showSuccessBanner = true;
// Fetch updated library list and update state manager
var updatedBooks = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
LibraryStateService.OwnedBooks = updatedBooks;
}
else
{
Console.WriteLine("[AiResponseRenderer] Purchase failed on server.");
}
}
catch (Exception ex)
{
Console.WriteLine($"[AiResponseRenderer] Error processing purchase: {ex.Message}");
}
finally
{
_isSimulatingPayment = false;
StateHasChanged();
}
}
private List<ResponseSegment> ParseSegments(string text)
{
var segments = new List<ResponseSegment>();
if (string.IsNullOrEmpty(text)) return segments;
var regex = new System.Text.RegularExpressions.Regex(
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var matches = regex.Matches(text);
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in matches)
{
if (match.Index > lastIndex)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex, match.Index - lastIndex),
IsCitation = false
});
}
var citationId = match.Groups[1].Success
? match.Groups[1].Value.Trim()
: match.Groups[2].Value.Trim();
segments.Add(new ResponseSegment
{
IsCitation = true,
CitationId = citationId
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex),
IsCitation = false
});
}
return segments;
}
private MarkupString RenderMarkdown(string text)
{
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
var html = System.Net.WebUtility.HtmlEncode(text);
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
html = html.Replace("\n", "<br />");
return new MarkupString(html);
}
}
@@ -0,0 +1,269 @@
.message-row {
display: flex;
gap: 1rem;
width: 100%;
max-width: 90%;
margin-bottom: 1.5rem;
animation: bubble-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.user-row {
align-self: flex-end;
margin-left: auto;
flex-direction: row-reverse;
}
.ai-row {
align-self: flex-start;
margin-right: auto;
}
.message-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.user-row .message-avatar {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
}
.ai-row .message-avatar {
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
color: #e6fffa;
border: 1px solid rgba(0, 255, 153, 0.4);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.25);
}
.message-bubble {
padding: 1.25rem 1.5rem;
border-radius: 16px;
position: relative;
line-height: 1.6;
font-size: 0.975rem;
display: flex;
flex-direction: column;
width: 100%;
}
.user-bubble {
background: #1a1a1e;
border: 1px solid rgba(255, 255, 255, 0.05);
color: #e4e4e7;
border-top-right-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.ai-bubble {
background: rgba(26, 26, 30, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
color: #e2e8f0;
border-top-left-radius: 4px;
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.2);
}
.paywalled-bubble {
border-color: rgba(16, 185, 129, 0.15);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.75rem;
opacity: 0.6;
}
.sender-name {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-time {
font-family: monospace;
}
.message-content {
word-break: break-word;
}
/* Paragraph spacing */
.message-content p {
margin: 0 0 1rem 0;
}
.message-content p:last-child {
margin-bottom: 0;
}
/* Paywall Blur Styles */
.teaser-blur {
position: relative;
filter: blur(5px);
pointer-events: none;
-webkit-user-select: none;
user-select: none;
opacity: 0.35;
margin-top: 1rem;
margin-bottom: 1.5rem;
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 100%);
mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 100%);
}
/* Upsell Card */
.upsell-card {
background: #1a1a1e;
border-radius: 12px;
border: 1px solid rgba(16, 185, 129, 0.2);
padding: 1.5rem;
margin-top: 1rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.upsell-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.upsell-icon {
font-size: 1.25rem;
}
.upsell-header h4 {
margin: 0;
color: #10b981;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.5px;
}
.upsell-text {
color: rgba(255, 255, 255, 0.75);
font-size: 0.9rem;
line-height: 1.55;
margin: 0 0 1.25rem 0;
}
.upsell-actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.btn-upsell {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
border-radius: 8px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
letter-spacing: 0.5px;
min-height: 44px;
}
.btn-primary {
background: #10b981;
border: none;
color: #121214;
}
.btn-primary:hover:not(:disabled) {
background: #0d9668;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-primary:disabled {
background: rgba(16, 185, 129, 0.5);
color: rgba(18, 18, 20, 0.6);
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
border: 1px solid #10b981;
color: #10b981;
}
.btn-secondary:hover {
background: rgba(16, 185, 129, 0.05);
transform: translateY(-2px);
}
.btn-secondary:active {
transform: translateY(0);
}
/* Success Banner */
.success-unlock-banner {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #10b981;
padding: 1rem;
border-radius: 8px;
margin-top: 1.25rem;
font-size: 0.9rem;
font-weight: 600;
animation: fade-in 0.5s ease-out;
}
.success-icon {
font-weight: bold;
font-size: 1.1rem;
}
/* Payment Spinner */
.payment-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(18, 18, 20, 0.2);
border-top-color: #121214;
border-radius: 50%;
margin-right: 0.75rem;
animation: spin 0.8s linear infinite;
}
/* Keyframes */
@keyframes bubble-fade-in {
0% { opacity: 0; transform: translateY(12px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes card-slide-in {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@@ -28,6 +28,12 @@ public class ChatMessage
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public List<ResponseSegment> Segments { get; set; } = new();
public List<CitationDto> Citations { get; set; } = new();
public string ClearText { get; set; } = string.Empty;
public string BlurredTeaserText { get; set; } = string.Empty;
public bool IsPaywalled { get; set; }
public string SourceBookTitle { get; set; } = string.Empty;
public string DocumentId { get; set; } = string.Empty;
}
/// <summary>
@@ -1,5 +1,6 @@
@page "/catalog"
@attribute [Authorize]
@implements IDisposable
@using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Services
@@ -7,6 +8,7 @@
@inject HttpClient Http
@inject IReaderNavigationService ReaderNavigation
@inject NavigationManager NavigationManager
@inject ILibraryStateService LibraryStateService
<div class="catalog-page">
<header class="catalog-header">
@@ -189,6 +191,11 @@
private bool _isLoading = true;
private List<LastReadBookDto>? _books;
protected override void OnInitialized()
{
LibraryStateService.OnBooksChanged += HandleBooksChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
@@ -197,6 +204,11 @@
}
}
private void HandleBooksChanged()
{
_ = InvokeAsync(LoadBooksAsync);
}
private async Task LoadBooksAsync()
{
_isLoading = true;
@@ -231,4 +243,9 @@
// Showcase callback
NavigationManager.NavigateTo("/profile");
}
public void Dispose()
{
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
}
}
@@ -1,35 +1,34 @@
@page "/intelligence"
@attribute [Authorize]
@implements IDisposable
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Models
@using System.Net.Http.Json
@inject HttpClient Http
@inject IKnowledgeService KnowledgeService
@inject AuthenticationStateProvider AuthStateProvider
@inject ILibraryStateService LibraryStateService
<div class="intelligence-page">
<header class="intelligence-header">
<div class="header-title-section">
<h1 class="neon-glow-text">Global Intelligence</h1>
<p class="subtitle">Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG</p>
</div>
</header>
<div class="intelligence-layout glass-panel">
<div class="intelligence-layout">
<div class="chat-thread-container">
@if (_chatMessages.Count == 0)
{
<div class="welcome-state">
<div class="welcome-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="#8b8273" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" stroke-dasharray="2 2" stroke="rgba(139, 130, 115, 0.3)" />
<path d="M12 6v12M8 8h8M6 12h12M8 16h8" stroke="rgba(139, 130, 115, 0.4)" />
<path d="M9.5 4.5c-1.5 0-3 1.5-3 3.5s1.5 3 3 3h5c1.5 0 3-1 3-3s-1.5-3.5-3-3.5" />
<path d="M9.5 19.5c-1.5 0-3-1.5-3-3.5s1.5-3 3-3h5c1.5 0 3 1 3 3s-1.5 3.5-3 3.5" />
<circle cx="12" cy="12" r="2" fill="#8b8273" />
</svg>
</div>
<h3>Start Interrogating Your Library</h3>
<p>Ask complex questions across your entire ebook collection. The KM-RAG engine dynamically builds semantic maps, resolves dependencies, and formulates high-fidelity, grounded answers with interactive popover citations.</p>
<div class="welcome-prompt">Zadaj pytanie globalne do całej biblioteki...</div>
</div>
}
else
@@ -37,37 +36,7 @@
<div class="chat-bubbles-scroll">
@foreach (var message in _chatMessages)
{
<div class="message-row @(message.Sender == "User" ? "user-row" : "ai-row")" key="@message.Id">
<div class="message-avatar">
@if (message.Sender == "User")
{
<i class="bi bi-person-fill"></i>
}
else
{
<i class="bi bi-robot"></i>
}
</div>
<div class="message-bubble @(message.Sender == "User" ? "user-bubble" : "ai-bubble")">
<div class="message-header">
<span class="sender-name">@message.Sender</span>
<span class="message-time">@message.Timestamp.ToString("HH:mm")</span>
</div>
<div class="message-content">
@foreach (var segment in message.Segments)
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
</div>
</div>
<AiResponseRenderer @key="message.Id" Message="@message" OwnedBooks="@_books" />
}
@if (_isLoading)
@@ -100,9 +69,9 @@
<div class="input-panel-wrapper">
<div class="scope-bar">
<div class="scope-selector">
<label for="book-select"><i class="bi bi-compass"></i> Scope:</label>
<label for="book-select">Scope:</label>
<select id="book-select" class="nexus-select" @bind="_selectedBookId">
<option value="">All Books (Global Search)</option>
<option value="">[ All Resources (Including Global Catalog) ]</option>
@if (_books != null)
{
@foreach (var book in _books)
@@ -121,7 +90,7 @@
@bind:event="oninput"
@onkeyup="HandleKeyUp"
disabled="@_isLoading" />
<button class="btn-nexus btn-nexus-primary search-btn"
<button class="search-btn"
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
@onclick="AskQuestionAsync">
@if (_isLoading)
@@ -146,20 +115,52 @@
private List<LastReadBookDto>? _books;
private List<ChatMessage> _chatMessages = new();
protected override async Task OnInitializedAsync()
{
LibraryStateService.OnBooksChanged += HandleBooksChanged;
await LoadBooksAsync();
}
private async Task LoadBooksAsync()
{
try
{
_books = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
LibraryStateService.OwnedBooks = _books;
}
catch (Exception ex)
{
Console.WriteLine($"[Intelligence] Failed to load books for scope selector: {ex.Message}");
Console.WriteLine($"[Intelligence] Failed to load books: {ex.Message}");
}
}
private void HandleBooksChanged()
{
_ = InvokeAsync(async () =>
{
_books = LibraryStateService.OwnedBooks;
// Check if any existing message in the chat thread was paywalled,
// and update its owned state dynamically.
if (_books != null)
{
foreach (var message in _chatMessages)
{
if (message.IsPaywalled && !string.IsNullOrEmpty(message.SourceBookTitle))
{
var isNowOwned = _books.Any(b => b.Title.Equals(message.SourceBookTitle, StringComparison.OrdinalIgnoreCase));
if (isNowOwned)
{
message.IsPaywalled = false;
}
}
}
}
StateHasChanged();
});
}
private async Task HandleKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_question) && !_isLoading)
@@ -176,7 +177,7 @@
_question = string.Empty; // Clear input field immediately
_isLoading = true;
// Add user query message
// Add user query message with custom background in renderer
_chatMessages.Add(new ChatMessage
{
Sender = "User",
@@ -201,13 +202,41 @@
if (result.IsSuccess)
{
var response = result.Value;
_chatMessages.Add(new ChatMessage
// --- Paywall Simulation Logic ---
// If the user does not own "Architektura .NET 10 i Ekosystem Blazor"
// and the question refers to Blazor/architecture/C#/etc,
// we simulate that the RAG search pulled a citation from that unowned book.
var hasBlazorBook = _books != null && _books.Any(b => b.Title.Contains("Architektura .NET 10", StringComparison.OrdinalIgnoreCase));
var isSimulatingPaywall = !hasBlazorBook &&
(userQuestion.Contains("blazor", StringComparison.OrdinalIgnoreCase) ||
userQuestion.Contains("net", StringComparison.OrdinalIgnoreCase) ||
userQuestion.Contains("architektura", StringComparison.OrdinalIgnoreCase) ||
userQuestion.Contains("c#", StringComparison.OrdinalIgnoreCase));
if (isSimulatingPaywall)
{
Sender = "AI",
Text = response.Answer,
Segments = ParseSegments(response.Answer),
Citations = response.Citations
});
var mockCitationId = Guid.NewGuid().ToString();
var mockCitation = new CitationDto
{
CitationId = mockCitationId,
SourceBook = "Architektura .NET 10 i Ekosystem Blazor",
Author = "Nexus Architect",
Snippet = "Konfiguracja kontenera dependency injection w standardzie .NET 10 Blazor przy użyciu Native AOT wymaga wyeliminowania dynamicznej refleksji.",
PageNumber = 42
};
if (response.Citations == null)
{
response.Citations = new List<CitationDto>();
}
response.Citations.Add(mockCitation);
response.Answer = "Aby poprawnie skonfigurować architekturę .NET 10 Blazor pod Native AOT, należy unikać dynamicznego ładowania typów. Konieczne jest używanie generatorów kodu źródłowego (Source Generators) do rejestracji zależności w kontenerze DI. W ten sposób kompilator AOT może przeanalizować graf zależności podczas kompilacji. [Source ID: " + mockCitationId + "]\n\nPełny przykładowy kod konfiguracji Program.cs wygląda następująco:\n```csharp\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddSingleton<IService, AotService>();\n```";
}
var chatMsg = CreateAiChatMessage(response, _books);
_chatMessages.Add(chatMsg);
}
else
{
@@ -237,12 +266,85 @@
}
}
private ChatMessage CreateAiChatMessage(GroundedResponseDto response, List<LastReadBookDto>? ownedBooks)
{
var msg = new ChatMessage
{
Sender = "AI",
Text = response.Answer,
Segments = ParseSegments(response.Answer),
Citations = response.Citations
};
// Check if paywalled: citations contain a book not in ownedBooks
var unownedCitation = response.Citations.FirstOrDefault(c =>
!string.IsNullOrEmpty(c.SourceBook) &&
(ownedBooks == null || !ownedBooks.Any(ob => ob.Title.Equals(c.SourceBook, StringComparison.OrdinalIgnoreCase))));
if (unownedCitation != null)
{
msg.IsPaywalled = true;
msg.SourceBookTitle = unownedCitation.SourceBook;
// Split sentences *once* during creation for Native AOT rendering performance
var (clear, _) = SplitSentences(response.Answer);
msg.ClearText = clear;
msg.BlurredTeaserText = "\n\n// [Blokada Paywall] Pełna treść oraz kody źródłowe C# zostały zablokowane.\npublic class ArchitekturaProcessor {\n public async Task ProcessAsync() {\n // Zaimplementuj wzorzec CQRS...\n throw new PaywallException(\"Kup publikację w katalogu\");\n }\n}";
}
else
{
msg.IsPaywalled = false;
msg.ClearText = response.Answer;
msg.BlurredTeaserText = string.Empty;
}
return msg;
}
private (string ClearText, string BlurredText) SplitSentences(string text)
{
if (string.IsNullOrEmpty(text)) return (string.Empty, string.Empty);
int sentenceCount = 0;
int firstSplit = -1;
int secondSplit = -1;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
if (c == '.' || c == '?' || c == '!')
{
if (i + 1 == text.Length || char.IsWhiteSpace(text[i + 1]))
{
sentenceCount++;
if (sentenceCount == 1)
{
firstSplit = i + 1;
}
else if (sentenceCount == 2)
{
secondSplit = i + 1;
break;
}
}
}
}
int splitIndex = secondSplit != -1 ? secondSplit : firstSplit;
if (splitIndex != -1 && splitIndex < text.Length)
{
return (text.Substring(0, splitIndex), text.Substring(splitIndex));
}
return (text, string.Empty);
}
private List<ResponseSegment> ParseSegments(string text)
{
var segments = new List<ResponseSegment>();
if (string.IsNullOrEmpty(text)) return segments;
// Matches [Source ID: some-id] OR raw GUIDs in brackets [e225e58f-7539-cd51-e0ab-82741ec7e65c]
var regex = new System.Text.RegularExpressions.Regex(
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
@@ -285,28 +387,8 @@
return segments;
}
private MarkupString RenderMarkdown(string text)
public void Dispose()
{
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
// 1. HTML Encode to prevent XSS
var html = System.Net.WebUtility.HtmlEncode(text);
// 2. Bold: **text** -> <strong>text</strong>
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
// 3. Italic: *text* -> <em>text</em>
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
// 4. Code blocks: ```language ... ``` -> <pre class="nexus-code-block"><code>...</code></pre>
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
// 5. Inline Code: `code` -> <code class="nexus-inline-code">code</code>
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
// 6. Newlines: \n -> <br />
html = html.Replace("\n", "<br />");
return new MarkupString(html);
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
}
}
@@ -1,33 +1,18 @@
.intelligence-page {
padding: 2rem;
max-width: 1100px;
margin: 0 auto;
height: calc(100vh - 100px);
margin: -2.5rem;
height: 100vh;
background: #121214;
display: flex;
flex-direction: column;
animation: fadeIn 0.5s ease-out;
overflow: hidden;
animation: fadeIn 0.4s ease-out;
}
.intelligence-header {
margin-bottom: 1.5rem;
flex-shrink: 0;
}
.neon-glow-text {
font-family: var(--nexus-font-sans);
font-size: 2.5rem;
font-weight: 800;
margin: 0 0 0.25rem 0;
background: linear-gradient(135deg, var(--nexus-neon) 0%, rgba(0, 255, 153, 0.7) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.2));
}
.subtitle {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
@media (max-width: 768px) {
.intelligence-page {
margin: -1.25rem;
height: calc(100vh - 60px);
}
}
.intelligence-layout {
@@ -35,202 +20,90 @@
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
height: 100%;
}
.chat-thread-container {
flex-grow: 1;
overflow-y: auto;
padding: 2rem;
padding: 3rem 4rem 2rem 4rem;
display: flex;
flex-direction: column;
}
@media (max-width: 768px) {
.chat-thread-container {
padding: 1.5rem 1rem;
}
}
/* Custom Scrollbars */
.chat-thread-container::-webkit-scrollbar {
width: 6px;
}
.chat-thread-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.01);
background: transparent;
}
.chat-thread-container::-webkit-scrollbar-thumb {
background: rgba(0, 255, 153, 0.2);
background: rgba(16, 185, 129, 0.2);
border-radius: 4px;
}
.chat-thread-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 255, 153, 0.4);
background: rgba(16, 185, 129, 0.4);
}
.chat-bubbles-scroll {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.message-row {
display: flex;
gap: 1rem;
width: 100%;
max-width: 85%;
animation: bubble-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.user-row {
align-self: flex-end;
flex-direction: row-reverse;
}
.ai-row {
align-self: flex-start;
}
.message-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
/* State 1: Initial Empty Screen */
.welcome-state {
text-align: center;
padding: 4rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
margin-top: auto;
margin-bottom: auto;
animation: fade-in-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.user-row .message-avatar {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
.welcome-icon {
margin-bottom: 2rem;
filter: drop-shadow(0 0 15px rgba(139, 130, 115, 0.15));
}
.ai-row .message-avatar {
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
color: #e6fffa;
border: 1px solid rgba(0, 255, 153, 0.4);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.25);
}
.message-bubble {
padding: 1.25rem 1.5rem;
border-radius: var(--radius-lg);
position: relative;
line-height: 1.6;
font-size: 0.975rem;
}
.user-bubble {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
border-top-right-radius: 4px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.ai-bubble {
background: rgba(10, 20, 30, 0.55);
border: 1px solid rgba(0, 255, 153, 0.2);
color: #e2e8f0;
border-top-left-radius: 4px;
box-shadow: 0 4px 15px rgba(0, 255, 153, 0.05);
flex-grow: 1;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.75rem;
opacity: 0.6;
}
.sender-name {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-time {
font-family: monospace;
}
.message-content {
word-break: break-word;
}
/* Paragraph Spacing & Markdown */
.message-content p {
margin: 0 0 1rem 0;
}
.message-content p:last-child {
margin-bottom: 0;
}
.nexus-code-block {
background: rgba(0, 0, 0, 0.4) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: var(--radius-sm);
padding: 1rem;
margin: 1rem 0;
overflow-x: auto;
font-family: 'Fira Code', monospace;
font-size: 0.85rem;
color: #a7f3d0;
}
.nexus-inline-code {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-family: monospace;
font-size: 0.9em;
color: #f472b6; /* Light pink for inline code */
}
/* Pending State Bubble */
.pending-bubble {
border-color: rgba(0, 255, 153, 0.4);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.1);
}
.typing-indicator {
display: flex;
gap: 4px;
align-items: center;
margin-bottom: 0.5rem;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: var(--nexus-neon);
border-radius: 50%;
display: inline-block;
animation: typing-bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
.loading-label {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
.welcome-prompt {
font-family: var(--nexus-font-sans, inherit);
color: #e4e4e7;
font-size: 1.35rem;
font-weight: 500;
letter-spacing: -0.2px;
}
/* Input Controls */
.chat-input-controls {
padding: 1.5rem 2rem 2rem 2rem;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem 4rem 3rem 4rem;
background: linear-gradient(to top, #121214 70%, rgba(18, 18, 20, 0));
flex-shrink: 0;
}
@media (max-width: 768px) {
.chat-input-controls {
padding: 1rem 1rem 1.5rem 1rem;
}
}
.input-panel-wrapper {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.scope-bar {
@@ -241,46 +114,48 @@
.scope-selector {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.6rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
font-weight: 500;
color: #8b8273;
}
.nexus-select {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #ffffff;
padding: 0.35rem 2rem 0.35rem 0.75rem;
border-radius: var(--radius-sm);
background: #1a1a1e;
border: 1px solid rgba(255, 255, 255, 0.06);
color: #e4e4e7;
padding: 0.4rem 2rem 0.4rem 0.75rem;
border-radius: 8px;
outline: none;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.3s ease;
font-size: 0.825rem;
transition: all 0.25s ease;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b8273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 0.85em;
}
.nexus-select:focus {
border-color: var(--nexus-neon);
box-shadow: 0 0 8px rgba(0, 255, 153, 0.2);
border-color: #10b981;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
}
.input-field-group {
display: flex;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md);
padding: 0.35rem;
background: #1a1a1e;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 0.4rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.input-field-group:focus-within {
border-color: var(--nexus-neon);
background: rgba(0, 255, 153, 0.01);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15);
border-color: rgba(16, 185, 129, 0.5);
background: #1a1a1e;
box-shadow: 0 10px 35px rgba(16, 185, 129, 0.1);
}
.nexus-input {
@@ -288,68 +163,92 @@
background: transparent;
border: none;
color: #ffffff;
font-size: 1rem;
font-size: 0.975rem;
outline: none;
padding: 0.5rem 1rem;
}
.nexus-input::placeholder {
color: rgba(255, 255, 255, 0.35);
color: #8b8273;
}
.search-btn {
width: 46px;
height: 46px;
padding: 0 !important;
width: 44px;
height: 44px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #10b981;
border: none;
color: #121214;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.welcome-state {
text-align: center;
color: rgba(255, 255, 255, 0.5);
padding: 4rem 2rem;
.search-btn:hover:not(:disabled) {
background: #0d9668;
transform: scale(1.02);
}
.search-btn:disabled {
background: rgba(26, 26, 30, 0.8);
color: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.02);
cursor: not-allowed;
}
/* Typing / Loading Indicators */
.message-bubble.pending-bubble {
border-color: rgba(16, 185, 129, 0.25);
background: rgba(16, 185, 129, 0.03);
max-width: 450px;
}
.typing-indicator {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
height: 100%;
margin-bottom: 0.6rem;
}
.welcome-icon {
color: rgba(0, 255, 153, 0.4);
margin-bottom: 1.5rem;
filter: drop-shadow(0 0 10px rgba(0, 255, 153, 0.2));
animation: pulse 2.5s infinite alternate;
.typing-indicator span {
width: 7px;
height: 7px;
background: #10b981;
border-radius: 50%;
display: inline-block;
animation: typing-bounce 1.4s infinite ease-in-out both;
}
.welcome-state h3 {
color: #ffffff;
font-size: 1.5rem;
margin: 0 0 0.75rem 0;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
.welcome-state p {
max-width: 550px;
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
.loading-label {
font-size: 0.825rem;
color: rgba(255, 255, 255, 0.45);
font-style: italic;
}
.btn-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(0, 0, 0, 0.1);
width: 18px;
height: 18px;
border: 2px solid rgba(18, 18, 20, 0.1);
border-radius: 50%;
border-top-color: #000000;
border-top-color: #121214;
animation: spin 0.8s linear infinite;
}
/* Keyframe Animations */
@keyframes bubble-fade-in {
0% { opacity: 0; transform: translateY(10px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-in-up {
0% { opacity: 0; transform: translateY(15px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes typing-bounce {
@@ -357,11 +256,6 @@
50% { transform: translateY(-4px); }
}
@keyframes pulse {
0% { transform: scale(0.96); opacity: 0.8; }
100% { transform: scale(1.04); opacity: 1; }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
@@ -1,11 +1,13 @@
@page "/my-books"
@attribute [Authorize]
@implements IDisposable
@using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Services
@using System.Net.Http.Json
@inject HttpClient Http
@inject IReaderNavigationService ReaderNavigation
@inject ILibraryStateService LibraryStateService
<div class="my-books-page">
<header class="my-books-header">
@@ -108,6 +110,11 @@
private bool _isLoading = true;
private List<LastReadBookDto>? _books;
protected override void OnInitialized()
{
LibraryStateService.OnBooksChanged += HandleBooksChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
@@ -116,6 +123,11 @@
}
}
private void HandleBooksChanged()
{
_ = InvokeAsync(LoadBooksAsync);
}
private async Task LoadBooksAsync()
{
_isLoading = true;
@@ -149,4 +161,9 @@
{
ReaderNavigation.NavigateToBook(bookId);
}
public void Dispose()
{
LibraryStateService.OnBooksChanged -= HandleBooksChanged;
}
}
@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using NexusReader.Application.DTOs.User;
namespace NexusReader.UI.Shared.Services;
public interface ILibraryStateService
{
List<LastReadBookDto>? OwnedBooks { get; set; }
event Action? OnBooksChanged;
void NotifyBooksChanged();
}
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using NexusReader.Application.DTOs.User;
namespace NexusReader.UI.Shared.Services;
public class LibraryStateService : ILibraryStateService
{
private List<LastReadBookDto>? _ownedBooks;
public List<LastReadBookDto>? OwnedBooks
{
get => _ownedBooks;
set
{
_ownedBooks = value;
NotifyBooksChanged();
}
}
public event Action? OnBooksChanged;
public void NotifyBooksChanged()
{
OnBooksChanged?.Invoke();
}
}
+1
View File
@@ -27,6 +27,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
+44
View File
@@ -57,6 +57,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
@@ -454,6 +455,48 @@ app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapPost("/api/library/purchase", async (
ClaimsPrincipal user,
[FromBody] PurchaseBookRequest request,
IDbContextFactory<AppDbContext> dbContextFactory) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
using var dbContext = await dbContextFactory.CreateDbContextAsync();
// Find or create author
var authorName = "Nexus Architect";
var author = await dbContext.Authors.FirstOrDefaultAsync(a => a.Name == authorName);
if (author == null)
{
author = new Author { Name = authorName };
dbContext.Authors.Add(author);
await dbContext.SaveChangesAsync();
}
// Check if the book already exists for the user
var bookExists = await dbContext.Ebooks.AnyAsync(e => e.UserId == userId && e.Title == request.Title);
if (!bookExists)
{
var newBook = new Ebook
{
Title = request.Title,
AuthorId = author.Id,
UserId = userId,
FilePath = "wwwroot/assets/book.epub",
AddedDate = DateTime.UtcNow,
Progress = 0,
Description = "Zaawansowany kurs budowania skalowalnych SaaS z Native AOT, CQRS, MediatR, FluentResults i izolowanym systemem stylów Blazor CSS.",
IsReadyForReading = true
};
dbContext.Ebooks.Add(newBook);
await dbContext.SaveChangesAsync();
}
return Results.Ok();
}).RequireAuthorization();
app.MapGet("/api/book/{bookId:guid}/concepts-map", async (
Guid bookId,
ClaimsPrincipal user,
@@ -729,3 +772,4 @@ public record KnowledgeRequest(string Text, Guid? EbookId = null);
public record GroundednessRequest(string Answer, string Context);
public record SemanticSearchRequest(string QueryText, int Limit = 5);
public record AskQuestionRequest(string Question, Guid? EbookId = null, int Limit = 5);
public record PurchaseBookRequest(string Title);
+1 -1
View File
@@ -9,5 +9,5 @@
"AllowRegistration": false,
"AllowPasswordReset": false
},
"ApiBaseUrl": "http://localhost:5000"
"ApiBaseUrl": "http://localhost:5104"
}
+1 -1
View File
@@ -31,5 +31,5 @@
"MaxOutputTokens": 8192
}
},
"ApiBaseUrl": "http://localhost:5000"
"ApiBaseUrl": "http://localhost:5104"
}