335 lines
11 KiB
Plaintext
335 lines
11 KiB
Plaintext
@namespace NexusReader.UI.Shared.Components.Atoms
|
||
@using System.Text.RegularExpressions
|
||
@using NexusReader.Application.DTOs.AI
|
||
@inject IKnowledgeService KnowledgeService
|
||
@inject IReaderNavigationService NavService
|
||
@inject IReaderInteractionService InteractionService
|
||
@inject NavigationManager NavManager
|
||
@inject AuthenticationStateProvider AuthStateProvider
|
||
@inject ILogger<NexusSearchBox> Logger
|
||
@implements IDisposable
|
||
|
||
<div class="nexus-search-container @(IsFocused ? "focused" : "") @(HasResults ? "has-results" : "")" @onfocusin="HandleFocusIn" @onfocusout="HandleFocusOut">
|
||
<div class="search-wrapper">
|
||
<div class="search-icon-container">
|
||
@if (_isLoading)
|
||
{
|
||
<div class="neon-spinner"></div>
|
||
}
|
||
else
|
||
{
|
||
<i class="nexus-icon bi bi-search"></i>
|
||
}
|
||
</div>
|
||
|
||
<input type="text"
|
||
value="@SearchValue"
|
||
@oninput="HandleInput"
|
||
@onkeydown="HandleKeyDown"
|
||
placeholder="@Placeholder"
|
||
class="nexus-search-input" />
|
||
|
||
<div class="ai-status-indicator" title="Aktywny silnik AI biblioteki">
|
||
<span class="ai-pulse-dot"></span>
|
||
</div>
|
||
|
||
@if (!string.IsNullOrEmpty(SearchValue))
|
||
{
|
||
<button type="button" class="clear-btn" @onclick="ClearSearch" aria-label="Wyczyść wyszukiwanie">×</button>
|
||
}
|
||
</div>
|
||
|
||
@if (_isDropdownOpen && (!string.IsNullOrEmpty(SearchValue) || _isLoading || _results.Any() || _searchError != null))
|
||
{
|
||
<div class="search-dropdown glass-panel">
|
||
@if (_isLoading)
|
||
{
|
||
<div class="dropdown-state-container">
|
||
<div class="neon-spinner-large"></div>
|
||
<span class="state-text">Analizowanie biblioteki semantycznej...</span>
|
||
</div>
|
||
}
|
||
else if (_searchError != null)
|
||
{
|
||
<div class="dropdown-state-container error">
|
||
<i class="bi bi-exclamation-triangle-fill error-icon"></i>
|
||
<span class="state-text">@_searchError</span>
|
||
</div>
|
||
}
|
||
else if (_results.Any())
|
||
{
|
||
<div class="dropdown-results-list">
|
||
@foreach (var result in _results)
|
||
{
|
||
<div class="result-card" @onclick="() => HandleResultClick(result)">
|
||
<div class="result-header">
|
||
<span class="relevance-badge">@(Math.Round(result.RelevanceScore * 100))% Trafności</span>
|
||
@if (!string.IsNullOrEmpty(result.SourceBookTitle))
|
||
{
|
||
<span class="source-title" title="@result.SourceBookTitle">w <strong>@result.SourceBookTitle</strong></span>
|
||
}
|
||
</div>
|
||
<div class="result-snippet">
|
||
@((MarkupString)HighlightQueryWords(result.Snippet, SearchValue))
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
else if (!string.IsNullOrEmpty(SearchValue))
|
||
{
|
||
<div class="dropdown-state-container empty">
|
||
<i class="bi bi-search empty-icon"></i>
|
||
<span class="state-text">Brak wyników dla zapytania.</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
@code {
|
||
[Parameter] public string Placeholder { get; set; } = "Zapytaj swoją bibliotekę AI...";
|
||
[Parameter] public EventCallback<string> OnSearch { get; set; }
|
||
[Parameter] public int Limit { get; set; } = 5;
|
||
|
||
private string SearchValue { get; set; } = string.Empty;
|
||
private bool IsFocused { get; set; }
|
||
private bool HasResults => _results.Any() && _isDropdownOpen;
|
||
|
||
private List<SemanticSearchResultDto> _results = new();
|
||
private bool _isLoading;
|
||
private string? _searchError;
|
||
private bool _isDropdownOpen;
|
||
|
||
private CancellationTokenSource? _searchCts;
|
||
|
||
private async Task HandleInput(ChangeEventArgs e)
|
||
{
|
||
SearchValue = e.Value?.ToString() ?? string.Empty;
|
||
_searchError = null;
|
||
|
||
if (string.IsNullOrWhiteSpace(SearchValue))
|
||
{
|
||
_results.Clear();
|
||
_isDropdownOpen = false;
|
||
return;
|
||
}
|
||
|
||
_isDropdownOpen = true;
|
||
|
||
// Cancel previous search in-flight
|
||
_searchCts?.Cancel();
|
||
_searchCts?.Dispose();
|
||
_searchCts = new CancellationTokenSource();
|
||
|
||
var token = _searchCts.Token;
|
||
|
||
try
|
||
{
|
||
// Debounce for 300ms
|
||
await Task.Delay(300, token);
|
||
await PerformSearchAsync(token);
|
||
}
|
||
catch (TaskCanceledException)
|
||
{
|
||
// Typing continued, search cancelled
|
||
}
|
||
}
|
||
|
||
private async Task PerformSearchAsync(CancellationToken token)
|
||
{
|
||
_isLoading = true;
|
||
_searchError = null;
|
||
await InvokeAsync(StateHasChanged);
|
||
|
||
try
|
||
{
|
||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||
|
||
var result = await KnowledgeService.SearchLibrarySemanticallyAsync(SearchValue, tenantId, Limit, token);
|
||
if (token.IsCancellationRequested) return;
|
||
|
||
if (result.IsSuccess)
|
||
{
|
||
_results = result.Value ?? new List<SemanticSearchResultDto>();
|
||
_searchError = null;
|
||
}
|
||
else
|
||
{
|
||
_results.Clear();
|
||
_searchError = result.Errors.FirstOrDefault()?.Message ?? "Nie udało się wykonać wyszukiwania.";
|
||
Logger.LogWarning("Semantic search returned errors: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message)));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
if (!token.IsCancellationRequested)
|
||
{
|
||
_results.Clear();
|
||
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
|
||
Logger.LogError(ex, "Unexpected error during semantic search for query: {Query}", SearchValue);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
if (!token.IsCancellationRequested)
|
||
{
|
||
_isLoading = false;
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task HandleResultClick(SemanticSearchResultDto result)
|
||
{
|
||
_isDropdownOpen = false;
|
||
|
||
// 1. Resolve Ebook ID
|
||
Guid? ebookId = null;
|
||
if (result.Metadata != null)
|
||
{
|
||
foreach (var key in new[] { "ebookId", "ebook_id", "EbookId", "Ebook_Id" })
|
||
{
|
||
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||
{
|
||
if (Guid.TryParse(val.ToString(), out var g))
|
||
{
|
||
ebookId = g;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (ebookId == null || ebookId == Guid.Empty)
|
||
{
|
||
ebookId = NavService.CurrentEbookId;
|
||
}
|
||
|
||
if (ebookId == null || ebookId == Guid.Empty)
|
||
{
|
||
Logger.LogWarning("Could not resolve ebook ID from search result metadata.");
|
||
return;
|
||
}
|
||
|
||
// 2. Resolve Chapter Index
|
||
int chapterIndex = 0;
|
||
if (result.Metadata != null)
|
||
{
|
||
foreach (var key in new[] { "chapterIndex", "chapter_index", "ChapterIndex", "chapter" })
|
||
{
|
||
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||
{
|
||
if (int.TryParse(val.ToString(), out var parsedInt))
|
||
{
|
||
chapterIndex = parsedInt;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Resolve Block ID
|
||
string? blockId = null;
|
||
if (result.Metadata != null)
|
||
{
|
||
foreach (var key in new[] { "blockId", "block_id", "BlockId", "nodeId", "node_id", "NodeId", "id" })
|
||
{
|
||
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||
{
|
||
blockId = val.ToString();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(blockId))
|
||
{
|
||
blockId = result.ContentHash;
|
||
}
|
||
|
||
// 4. Set pending scroll and navigate
|
||
NavService.PendingScrollBlockId = blockId;
|
||
|
||
if (NavService.CurrentEbookId == ebookId.Value && NavService.CurrentChapterIndex == chapterIndex)
|
||
{
|
||
// Same chapter - scroll and highlight immediately
|
||
if (!string.IsNullOrEmpty(blockId))
|
||
{
|
||
await InteractionService.RequestScrollToBlock(blockId);
|
||
await InteractionService.RequestHighlightBlock(blockId);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Different chapter or book - perform routing
|
||
NavService.SetBook(ebookId.Value, chapterIndex);
|
||
NavManager.NavigateTo($"/reader/{ebookId.Value}?chapter={chapterIndex}");
|
||
}
|
||
|
||
// Invoke the optional callback for parent components
|
||
await OnSearch.InvokeAsync(SearchValue);
|
||
}
|
||
|
||
private void HandleKeyDown(KeyboardEventArgs e)
|
||
{
|
||
if (e.Key == "Escape")
|
||
{
|
||
_isDropdownOpen = false;
|
||
}
|
||
}
|
||
|
||
private void HandleFocusIn()
|
||
{
|
||
IsFocused = true;
|
||
_isDropdownOpen = true;
|
||
}
|
||
|
||
private async Task HandleFocusOut()
|
||
{
|
||
IsFocused = false;
|
||
// Delay slightly to allow click handlers on result cards to execute
|
||
await Task.Delay(200);
|
||
_isDropdownOpen = false;
|
||
StateHasChanged();
|
||
}
|
||
|
||
private void ClearSearch()
|
||
{
|
||
SearchValue = string.Empty;
|
||
_results.Clear();
|
||
_searchError = null;
|
||
_isDropdownOpen = false;
|
||
}
|
||
|
||
private string HighlightQueryWords(string text, string query)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query))
|
||
return text;
|
||
|
||
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.Where(w => w.Length > 2)
|
||
.Select(Regex.Escape);
|
||
|
||
if (!words.Any())
|
||
return text;
|
||
|
||
var pattern = "(" + string.Join("|", words) + ")";
|
||
try
|
||
{
|
||
return Regex.Replace(text, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
|
||
}
|
||
catch
|
||
{
|
||
return text;
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
_searchCts?.Cancel();
|
||
_searchCts?.Dispose();
|
||
}
|
||
}
|