711480f8f6
This pull request introduces the dedicated containerized infrastructure and configuration for deploying NexusReader's beta version in the Test environment. ### Summary of Changes 1. **Docker Infrastructure & Secrets**: - **`docker-compose.test.yml`**: Configured dedicated database and auxiliary services (PostgreSQL 17, Qdrant, Neo4j) on isolated, non-standard ports to ensure zero conflict with the existing server configurations. - **`.env.test.template`**: Provided an environment variable template showing required setups, including mandatory database passwords, API keys, and admin custom passwords. - **`.gitignore`**: Excluded local `.env` files to prevent accidental commits of production or staging secrets. 2. **Database Hardening**: - Configured Neo4j with basic authentication (`IDriver` instantiation uses basic auth when credentials are provided in configuration). - Configured PostgreSQL to use mandatory authentication. - Configured the admin seeder (`DbInitializer.cs`) to dynamically use `NEXUS_ADMIN_PASSWORD` from environment variables, falling back to a default password in local Development only. 3. **Feature-Flagged Restrictions**: - **`appsettings.Test.json`**: Implemented `Features:AllowRegistration` and `Features:AllowPasswordReset` flags set to `false`. - **Middleware Enforcement (`Program.cs`)**: Intercepts requests to `/identity/register` and `/identity/forgotPassword` (and their MVC/form variations) and rejects them with a `403 Forbidden` response in restricted environments. - **OAuth Provisioning Guard (`Program.cs`)**: Blocks new account provisioning via Google OAuth callback by checking the `Features:AllowRegistration` configuration, redirecting users to the login page with a descriptive error. - **UI Protection (`Login.razor`, `Register.razor`)**: Conditionally hides registration/password reset links and intercepts manual navigation attempts to `/account/register` by redirecting to login with a warning. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #56 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
368 lines
14 KiB
Plaintext
368 lines
14 KiB
Plaintext
@using NexusReader.Application.DTOs.AI
|
|
@using NexusReader.Application.Abstractions.Services
|
|
@using NexusReader.Application.DTOs.User
|
|
@using NexusReader.UI.Shared.Components.Atoms
|
|
@using NexusReader.UI.Shared.Services
|
|
@using NexusReader.UI.Shared.Models
|
|
@using System.Net.Http.Json
|
|
@namespace NexusReader.UI.Shared.Components.Organisms
|
|
@inject HttpClient Http
|
|
@inject IKnowledgeService KnowledgeService
|
|
@inject IReaderNavigationService NavigationService
|
|
|
|
<div class="global-intelligence-sheet @(IsOpen ? "is-open" : "")">
|
|
<div class="sheet-backdrop" @onclick="HandleClose"></div>
|
|
<div class="sheet-content">
|
|
<div class="sheet-drag-handle"></div>
|
|
|
|
<header class="sheet-header">
|
|
<div class="header-main">
|
|
<div class="ai-avatar-badge">
|
|
<NexusIcon Name="robot" Size="20" Class="neon-glow" />
|
|
</div>
|
|
<div class="header-info">
|
|
<h3>Asystent AI Nexus</h3>
|
|
<p class="subtitle">Zadawaj pytania do swojej biblioteki</p>
|
|
</div>
|
|
</div>
|
|
<button class="close-btn" @onclick="HandleClose" aria-label="Zamknij">
|
|
<NexusIcon Name="close" Size="20" />
|
|
</button>
|
|
</header>
|
|
|
|
<div class="sheet-body">
|
|
<div class="chat-thread" id="mobile-chat-thread">
|
|
@if (_chatMessages.Count == 0)
|
|
{
|
|
<div class="welcome-container">
|
|
<div class="welcome-glow-icon">
|
|
<NexusIcon Name="brain" Size="32" Class="neon-glow" />
|
|
</div>
|
|
<h4>Zadaj pytanie asystentowi</h4>
|
|
<p>KM-RAG przeszukuje całą treść książki, wyciąga semantyczne powiązania i generuje precyzyjne odpowiedzi wraz z przypisami źródłowymi.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
@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")
|
|
{
|
|
<NexusIcon Name="user" Size="16" />
|
|
}
|
|
else
|
|
{
|
|
<NexusIcon Name="robot" Size="16" />
|
|
}
|
|
</div>
|
|
<div class="message-bubble @(message.Sender == "User" ? "user-bubble" : "ai-bubble")">
|
|
<div class="message-meta">
|
|
<span class="sender-name">@(message.Sender == "User" ? "Ty" : "Asystent")</span>
|
|
<span class="message-time">@message.Timestamp.ToString("HH:mm")</span>
|
|
</div>
|
|
<div class="message-text">
|
|
@foreach (var segment in message.Segments)
|
|
{
|
|
@if (segment.IsCitation)
|
|
{
|
|
<span class="nexus-mobile-citation" @onclick="() => HandleCitationClick(segment.CitationId)">
|
|
[@segment.CitationId]
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
@RenderMarkdown(segment.Text)
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (_isLoading)
|
|
{
|
|
<div class="message-row ai-row">
|
|
<div class="message-avatar">
|
|
<NexusIcon Name="robot" Size="16" />
|
|
</div>
|
|
<div class="message-bubble ai-bubble pending-bubble">
|
|
<div class="message-meta">
|
|
<span class="sender-name">Asystent</span>
|
|
<span class="message-time">Generowanie...</span>
|
|
</div>
|
|
<div class="message-text">
|
|
<div class="typing-indicator">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</div>
|
|
<span class="loading-label">Analiza grafu pojęć...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="sheet-footer">
|
|
<div class="scope-indicator">
|
|
<NexusIcon Name="map" Size="12" />
|
|
<span>Obszar: <strong>@(string.IsNullOrEmpty(_activeBookTitle) ? "Cała biblioteka" : _activeBookTitle)</strong></span>
|
|
</div>
|
|
|
|
<div class="input-container">
|
|
<input type="text"
|
|
class="nexus-mobile-input"
|
|
placeholder="Zadaj pytanie..."
|
|
@bind="_question"
|
|
@bind:event="oninput"
|
|
@onkeyup="HandleKeyUp"
|
|
disabled="@_isLoading" />
|
|
|
|
<button class="send-btn @(string.IsNullOrWhiteSpace(_question) || _isLoading ? "disabled" : "")"
|
|
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
|
|
@onclick="AskQuestionAsync">
|
|
@if (_isLoading)
|
|
{
|
|
<div class="btn-spinner"></div>
|
|
}
|
|
else
|
|
{
|
|
<NexusIcon Name="send" Size="18" />
|
|
}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
|
|
@if (_selectedCitation != null)
|
|
{
|
|
<div class="citation-modal-overlay" @onclick="CloseCitationModal">
|
|
<div class="citation-modal glass-panel" @onclick:stopPropagation>
|
|
<div class="modal-header">
|
|
<span class="book-title"><NexusIcon Name="map" Size="14" /> @_selectedCitation.SourceBook</span>
|
|
<button class="close-btn" @onclick="CloseCitationModal" aria-label="Close">
|
|
<NexusIcon Name="close" Size="16" />
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
@if (!string.IsNullOrEmpty(_selectedCitation.Author))
|
|
{
|
|
<p class="citation-author"><strong>Autor:</strong> @_selectedCitation.Author</p>
|
|
}
|
|
@if (_selectedCitation.PageNumber.HasValue)
|
|
{
|
|
<p class="citation-page"><strong>Strona:</strong> @_selectedCitation.PageNumber.Value</p>
|
|
}
|
|
<p class="citation-snippet">"@_selectedCitation.Snippet"</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn-nexus" @onclick="CloseCitationModal">Zamknij</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@code {
|
|
private static readonly System.Text.RegularExpressions.Regex CitationRegex = new(
|
|
@"\[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 | System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
private static readonly System.Text.RegularExpressions.Regex BoldRegex = new(
|
|
@"\*\*(.*?)\*\*",
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
private static readonly System.Text.RegularExpressions.Regex ItalicRegex = new(
|
|
@"\*(.*?)\*",
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
private static readonly System.Text.RegularExpressions.Regex CodeBlockRegex = new(
|
|
@"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```",
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
private static readonly System.Text.RegularExpressions.Regex InlineCodeRegex = new(
|
|
@"`(.*?)`",
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
[Parameter] public bool IsOpen { get; set; }
|
|
[Parameter] public EventCallback OnClose { get; set; }
|
|
[Parameter] public string? TenantId { get; set; }
|
|
|
|
private string _question = string.Empty;
|
|
private bool _isLoading;
|
|
private string _activeBookTitle = string.Empty;
|
|
private List<ChatMessage> _chatMessages = new();
|
|
private CitationDto? _selectedCitation;
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (IsOpen && string.IsNullOrEmpty(_activeBookTitle) && NavigationService.CurrentEbookId != Guid.Empty)
|
|
{
|
|
_activeBookTitle = NavigationService.ChapterTitle ?? "Aktywna książka";
|
|
}
|
|
}
|
|
|
|
private async Task HandleClose()
|
|
{
|
|
if (OnClose.HasDelegate)
|
|
{
|
|
await OnClose.InvokeAsync();
|
|
}
|
|
}
|
|
|
|
private async Task HandleKeyUp(KeyboardEventArgs e)
|
|
{
|
|
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_question) && !_isLoading)
|
|
{
|
|
await AskQuestionAsync();
|
|
}
|
|
}
|
|
|
|
private void HandleCitationClick(string citationId)
|
|
{
|
|
_selectedCitation = _chatMessages
|
|
.SelectMany(m => m.Citations)
|
|
.FirstOrDefault(c => c.CitationId.Equals(citationId, StringComparison.OrdinalIgnoreCase))
|
|
?? new CitationDto
|
|
{
|
|
CitationId = citationId,
|
|
SourceBook = "Grounded Document Chunk",
|
|
Snippet = "Context snippet retrieved from vector search node."
|
|
};
|
|
}
|
|
|
|
private void CloseCitationModal()
|
|
{
|
|
_selectedCitation = null;
|
|
}
|
|
|
|
private async Task AskQuestionAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
|
|
|
|
var userQuestion = _question;
|
|
_question = string.Empty;
|
|
_isLoading = true;
|
|
|
|
_chatMessages.Add(new ChatMessage
|
|
{
|
|
Sender = "User",
|
|
Text = userQuestion,
|
|
Segments = new List<ResponseSegment> { new ResponseSegment { Text = userQuestion, IsCitation = false } }
|
|
});
|
|
|
|
StateHasChanged();
|
|
|
|
try
|
|
{
|
|
Guid? ebookId = null;
|
|
if (NavigationService.CurrentEbookId != Guid.Empty)
|
|
{
|
|
ebookId = NavigationService.CurrentEbookId;
|
|
}
|
|
|
|
var tenantId = TenantId ?? "global";
|
|
|
|
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
|
|
if (result.IsSuccess)
|
|
{
|
|
var response = result.Value;
|
|
_chatMessages.Add(new ChatMessage
|
|
{
|
|
Sender = "AI",
|
|
Text = response.Answer,
|
|
Segments = ParseSegments(response.Answer),
|
|
Citations = response.Citations
|
|
});
|
|
}
|
|
else
|
|
{
|
|
var errMsg = $"Błąd: {result.Errors.FirstOrDefault()?.Message ?? "Wystąpił nieznany problem."}";
|
|
_chatMessages.Add(new ChatMessage
|
|
{
|
|
Sender = "AI",
|
|
Text = errMsg,
|
|
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
|
});
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var errMsg = $"Błąd sieci/API: {ex.Message}";
|
|
_chatMessages.Add(new ChatMessage
|
|
{
|
|
Sender = "AI",
|
|
Text = errMsg,
|
|
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
_isLoading = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private List<ResponseSegment> ParseSegments(string text)
|
|
{
|
|
var segments = new List<ResponseSegment>();
|
|
if (string.IsNullOrEmpty(text)) return segments;
|
|
|
|
var matches = CitationRegex.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 = BoldRegex.Replace(html, "<strong>$1</strong>");
|
|
html = ItalicRegex.Replace(html, "<em>$1</em>");
|
|
html = CodeBlockRegex.Replace(html, "<pre class=\"nexus-mobile-code-block\"><code>$1</code></pre>");
|
|
html = InlineCodeRegex.Replace(html, "<code class=\"nexus-mobile-inline-code\">$1</code>");
|
|
html = html.Replace("\n", "<br />");
|
|
|
|
return new MarkupString(html);
|
|
}
|
|
}
|