feat: implement semantic search, knowledge unit extraction, and visualization components
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
@namespace NexusReader.UI.Shared.Components.Atoms
|
||||
|
||||
<div class="nexus-search-container @(IsActive ? "active" : "")">
|
||||
<div class="search-wrapper">
|
||||
<i class="nexus-icon @IconClass"></i>
|
||||
<input type="text"
|
||||
@bind="SearchValue"
|
||||
@bind:event="oninput"
|
||||
@onkeypress="HandleKeyPress"
|
||||
placeholder="@Placeholder"
|
||||
class="nexus-search-input" />
|
||||
@if (!string.IsNullOrEmpty(SearchValue))
|
||||
{
|
||||
<button class="clear-btn" @onclick="ClearSearch">×</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Placeholder { get; set; } = "Search your library...";
|
||||
[Parameter] public string IconClass { get; set; } = "bi bi-search";
|
||||
[Parameter] public EventCallback<string> OnSearch { get; set; }
|
||||
|
||||
private string SearchValue { get; set; } = string.Empty;
|
||||
private bool IsActive => !string.IsNullOrEmpty(SearchValue);
|
||||
|
||||
private async Task HandleKeyPress(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
await OnSearch.InvokeAsync(SearchValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearSearch()
|
||||
{
|
||||
SearchValue = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.nexus-search-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 1rem auto;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--nexus-card, #141414);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.nexus-search-container.active .search-wrapper,
|
||||
.search-wrapper:focus-within {
|
||||
border-color: var(--nexus-neon, #00ff99);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.2);
|
||||
}
|
||||
|
||||
.nexus-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.nexus-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nexus-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@using MediatR
|
||||
@using NexusReader.Application.Commands.AI
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@inject IMediator Mediator
|
||||
|
||||
<div class="groundedness-badge @GetStatusClass()" title="@_result?.Rationale">
|
||||
@if (_isChecking)
|
||||
{
|
||||
<span class="shimmer">Weryfikacja...</span>
|
||||
}
|
||||
else if (_result != null)
|
||||
{
|
||||
<NexusIcon Name="@(GetIcon())" Size="14" />
|
||||
<span>@((_result.Score * 100).ToString("0"))% Grounded</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.groundedness-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.groundedness-badge.status-high { color: var(--nexus-neon); border-color: var(--nexus-neon); }
|
||||
.groundedness-badge.status-medium { color: #ffaa00; border-color: #ffaa00; }
|
||||
.groundedness-badge.status-low { color: #ff4444; border-color: #ff4444; }
|
||||
|
||||
.shimmer { opacity: 0.6; }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Answer { get; set; } = string.Empty;
|
||||
[Parameter] public string Context { get; set; } = string.Empty;
|
||||
|
||||
private GroundednessResult? _result;
|
||||
private bool _isChecking;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Answer) && !string.IsNullOrEmpty(Context) && _result == null)
|
||||
{
|
||||
await RunCheck();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCheck()
|
||||
{
|
||||
_isChecking = true;
|
||||
StateHasChanged();
|
||||
|
||||
var res = await Mediator.Send(new VerifyGroundednessCommand(Answer, Context));
|
||||
if (res.IsSuccess)
|
||||
{
|
||||
_result = res.Value;
|
||||
}
|
||||
|
||||
_isChecking = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private string GetStatusClass()
|
||||
{
|
||||
if (_result == null) return "";
|
||||
if (_result.Score >= 0.8) return "status-high";
|
||||
if (_result.Score >= 0.5) return "status-medium";
|
||||
return "status-low";
|
||||
}
|
||||
|
||||
private string GetIcon()
|
||||
{
|
||||
if (_result == null) return "help";
|
||||
if (_result.Score >= 0.8) return "check-circle";
|
||||
if (_result.Score >= 0.5) return "info-circle";
|
||||
return "alert-triangle";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@namespace NexusReader.UI.Shared.Components.Organisms
|
||||
|
||||
<div class="global-intelligence-panel">
|
||||
<div class="panel-header">
|
||||
<h3><i class="bi bi-cpu"></i> Global Intelligence</h3>
|
||||
<p>Semantic search across your library</p>
|
||||
</div>
|
||||
|
||||
<NexusSearchBox Placeholder="Ask a question about your books..." OnSearch="HandleSearch" />
|
||||
|
||||
<div class="results-container">
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="loading-state">
|
||||
<div class="nexus-spinner"></div>
|
||||
<span>Analyzing your library...</span>
|
||||
</div>
|
||||
}
|
||||
else if (Results != null && Results.Any())
|
||||
{
|
||||
@foreach (var result in Results)
|
||||
{
|
||||
<div class="search-result-item">
|
||||
<div class="result-meta">
|
||||
<span class="relevance">@(Math.Round(result.RelevanceScore * 100))% Relevant</span>
|
||||
@if (!string.IsNullOrEmpty(result.SourceBookTitle))
|
||||
{
|
||||
<span class="source">in <strong>@result.SourceBookTitle</strong></span>
|
||||
}
|
||||
</div>
|
||||
<div class="result-snippet">
|
||||
@result.Snippet
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (HasSearched)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-search"></i>
|
||||
<p>No semantic matches found.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<SemanticSearchResultDto>? Results { get; set; }
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback<string> OnPerformSearch { get; set; }
|
||||
|
||||
private bool HasSearched { get; set; }
|
||||
|
||||
private async Task HandleSearch(string query)
|
||||
{
|
||||
HasSearched = true;
|
||||
await OnPerformSearch.InvokeAsync(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.global-intelligence-panel {
|
||||
background: var(--nexus-bg, #0a0a0a);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.25rem 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 1rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
background: var(--nexus-card, #141414);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
transform: translateX(4px);
|
||||
border-color: rgba(0, 255, 153, 0.3);
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.relevance {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.result-snippet {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nexus-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 2px solid rgba(0, 255, 153, 0.1);
|
||||
border-top-color: var(--nexus-neon, #00ff99);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -122,13 +122,19 @@ export function updateData(data) {
|
||||
// Update Links
|
||||
link = rootGroup.select(".links-layer")
|
||||
.selectAll("path")
|
||||
.data(data.links, d => d.source + "-" + d.target)
|
||||
.data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
|
||||
.join(
|
||||
enter => enter.append("path")
|
||||
.attr("stroke", "rgba(255,255,255,0.05)")
|
||||
.attr("stroke", d => {
|
||||
if (d.relationType === 'Defines') return 'var(--nexus-accent)';
|
||||
if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)';
|
||||
if (d.relationType === 'Contains') return 'var(--nexus-neon)';
|
||||
return 'rgba(255,255,255,0.1)';
|
||||
})
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 1.5)
|
||||
.call(e => e.transition().duration(500).attr("stroke", "rgba(255,255,255,0.1)")),
|
||||
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
|
||||
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
|
||||
.call(e => e.transition().duration(500).attr("opacity", 1)),
|
||||
update => update,
|
||||
exit => exit.remove()
|
||||
);
|
||||
@@ -150,7 +156,12 @@ export function updateData(data) {
|
||||
|
||||
g.append("circle")
|
||||
.attr("r", 30)
|
||||
.attr("fill", "url(#nebulaGlow)")
|
||||
.attr("fill", d => {
|
||||
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
||||
if (d.type === 'Table') return 'var(--nexus-neon)';
|
||||
if (d.type === 'Rule') return '#ff4444';
|
||||
return "url(#nebulaGlow)";
|
||||
})
|
||||
.attr("opacity", 0)
|
||||
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
|
||||
|
||||
@@ -162,14 +173,18 @@ export function updateData(data) {
|
||||
.attr("height", 24)
|
||||
.attr("rx", 12)
|
||||
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
||||
.attr("stroke", "rgba(255, 255, 255, 0.1)")
|
||||
.attr("stroke", d => {
|
||||
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
||||
if (d.type === 'Rule') return '#ff4444';
|
||||
return "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("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
|
||||
.attr("font-size", "0.8rem");
|
||||
|
||||
return g;
|
||||
|
||||
Reference in New Issue
Block a user