feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error (#51)
Resolves #52 This Pull Request introduces the **NexusSearchBox** search feature with premium unified styling, implements a robust **dynamic Qdrant collection auto-provisioning and batch-vector ingestion pipeline**, integrates a unified **Serilog logging infrastructure** for the Blazor Hybrid environment (MAUI), and resolves the **401 Unauthorized API header propagation error** inside mobile builds. ### 🚀 Key Implementations #### 1. Premium `NexusSearchBox` & Semantic Search UI * **NexusSearchBox Component:** Created an elegant search-as-you-type search box with smooth key navigation, quick-clearing, and seamless dynamic styling. * **Unified Aesthetics:** Refactored the search box isolated styling to align perfectly with the dashboard's design system using glassmorphism, `--nexus-neon` token gradients, and smooth pulse/fade animations. * **Semantic Search Integration:** Integrated semantic search query dispatching (`SearchLibrarySemanticallyQuery`) and wired up navigation seamlessly through the updated `ReaderNavigationService`. * **Tests Hardening:** Added/adapted query assertions in `QueryTests.cs` to guarantee safe parameterization and error boundary mapping. #### 2. Qdrant Collection Provisioning & Vector Ingestion * **Dynamic Auto-Provisioning:** Implemented dynamic checking and lazy-creation of the `knowledge_units` collection using 768 dimensions and Cosine distance. * **High-Performance Ingestion:** Optimized `ProcessKnowledgeUnitsAsync` with high-performance batch embedding generation using `_embeddingGenerator` and deterministic MD5 GUIDs for stable, duplicate-free upsertion. * **Database Cache Clear Sync:** Integrated Qdrant collection deletion in `ClearCacheAsync` to ensure absolute consistency between the PostgreSQL database cache and vector database indices. #### 3. Cross-Platform MAUI Logging (Serilog Infrastructure) * **Serilog Integration:** Configured cross-platform Serilog routing in `SerilogConfiguration.cs`, streaming diagnostic logs safely across native platforms and the Blazor Webview container. * **Interop Bridge:** Built `BlazorLoggingBridge.cs` to capture web console messages and pipe them directly to the native host logger. * **Demo Interface:** Added an interactive `SerilogDemo.razor` sandbox under Pages. #### 4. Resolving 401 Load Errors (Authentication Handler Flow) * **Authentication Header Handler:** Implemented the `MobileAuthenticationHeaderHandler` to correctly extract, validate, and inject bearer JWT tokens into outbound API requests. * **Configuration-based API Host:** Structured standard API URI routing to use clean configuration bindings in `appsettings.json`. --- ### 🧪 Verification & Build Status * Run `dotnet build` from the solution root: Successfully compiled the full multi-targeted solution (`Liczba błędów: 0`). * All unit and integration tests successfully executed and verified (`dotnet test`). --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Co-authored-by: Marek Jaisński <jasins.marek@gmail.com> Reviewed-on: #51 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #51.
This commit is contained in:
@@ -3,6 +3,110 @@ import * as d3 from 'https://esm.sh/d3@7';
|
||||
const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label;
|
||||
const getPillWidth = d => getDisplayLabel(d).length * 8 + 30;
|
||||
|
||||
const getNodeType = d => {
|
||||
if (d) {
|
||||
if (d.type) {
|
||||
const t = d.type.toLowerCase();
|
||||
if (t === 'definition') return 'definition';
|
||||
if (t === 'table') return 'table';
|
||||
if (t === 'rule') return 'rule';
|
||||
if (t === 'section') return 'section';
|
||||
}
|
||||
if (d.group) {
|
||||
const g = d.group.toLowerCase();
|
||||
if (g === 'definition') return 'definition';
|
||||
if (g === 'table') return 'table';
|
||||
if (g === 'rule') return 'rule';
|
||||
if (g === 'section') return 'section';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getNodeGroup = d => {
|
||||
if (d && d.group) {
|
||||
const g = d.group.toLowerCase();
|
||||
if (g === 'bridge') return 'bridge';
|
||||
if (g === 'current') return 'current';
|
||||
if (g === 'concept') return 'concept';
|
||||
}
|
||||
return 'concept'; // fallback
|
||||
};
|
||||
|
||||
const getCategoryStyle = d => {
|
||||
const type = getNodeType(d);
|
||||
const group = getNodeGroup(d);
|
||||
|
||||
// 1. Rule (red/coral)
|
||||
if (type === 'rule') {
|
||||
return {
|
||||
color: '#ff4646',
|
||||
fill: 'rgba(255, 70, 70, 0.1)',
|
||||
opacity: 0.8,
|
||||
glowKey: 'rule',
|
||||
textColor: '#ff8b8b'
|
||||
};
|
||||
}
|
||||
// 2. Definition (gold/amber)
|
||||
if (type === 'definition') {
|
||||
return {
|
||||
color: '#ffb03a',
|
||||
fill: 'rgba(255, 176, 58, 0.1)',
|
||||
opacity: 0.8,
|
||||
glowKey: 'definition',
|
||||
textColor: '#ffd18c'
|
||||
};
|
||||
}
|
||||
// 3. Table (purple/magenta)
|
||||
if (type === 'table') {
|
||||
return {
|
||||
color: '#d946ef',
|
||||
fill: 'rgba(217, 70, 239, 0.1)',
|
||||
opacity: 0.8,
|
||||
glowKey: 'table',
|
||||
textColor: '#f5d0fe'
|
||||
};
|
||||
}
|
||||
// 4. Section (blue/indigo)
|
||||
if (type === 'section') {
|
||||
return {
|
||||
color: '#3b82f6',
|
||||
fill: 'rgba(59, 130, 246, 0.1)',
|
||||
opacity: 0.8,
|
||||
glowKey: 'section',
|
||||
textColor: '#93c5fd'
|
||||
};
|
||||
}
|
||||
// 5. Bridge (cyan/comparison)
|
||||
if (group === 'bridge') {
|
||||
return {
|
||||
color: '#06b6d4',
|
||||
fill: 'rgba(6, 182, 212, 0.1)',
|
||||
opacity: 0.7,
|
||||
glowKey: 'bridge',
|
||||
textColor: '#67e8f9'
|
||||
};
|
||||
}
|
||||
// 6. Current (active/focus landmark - neon green)
|
||||
if (group === 'current') {
|
||||
return {
|
||||
color: 'var(--nexus-neon)',
|
||||
fill: 'rgba(0, 255, 153, 0.15)',
|
||||
opacity: 0.9,
|
||||
glowKey: 'current',
|
||||
textColor: '#ffffff'
|
||||
};
|
||||
}
|
||||
// 7. Concept / Default (subtle cool steel blue/teal)
|
||||
return {
|
||||
color: '#00d2c4',
|
||||
fill: 'rgba(0, 210, 196, 0.05)',
|
||||
opacity: 0.4,
|
||||
glowKey: 'concept',
|
||||
textColor: '#e0e0e0'
|
||||
};
|
||||
};
|
||||
|
||||
let simulation;
|
||||
let zoomBehavior;
|
||||
let svgElement;
|
||||
@@ -24,8 +128,10 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
.attr("height", "100%")
|
||||
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
|
||||
|
||||
// Radial gradient for Nebula effect
|
||||
// Radial gradients for Nebula effects
|
||||
const defs = svgElement.append("defs");
|
||||
|
||||
// Fallback radial gradient for legacy nebulaGlow
|
||||
const radialGradient = defs.append("radialGradient")
|
||||
.attr("id", "nebulaGlow")
|
||||
.attr("cx", "50%")
|
||||
@@ -34,6 +140,26 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
|
||||
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
|
||||
|
||||
const colors = {
|
||||
'rule': '#ff4646',
|
||||
'definition': '#ffb03a',
|
||||
'table': '#d946ef',
|
||||
'section': '#3b82f6',
|
||||
'bridge': '#06b6d4',
|
||||
'current': 'var(--nexus-neon)',
|
||||
'concept': '#00d2c4'
|
||||
};
|
||||
|
||||
Object.entries(colors).forEach(([key, color]) => {
|
||||
const radGrad = defs.append("radialGradient")
|
||||
.attr("id", `nebulaGlow-${key}`)
|
||||
.attr("cx", "50%")
|
||||
.attr("cy", "50%")
|
||||
.attr("r", "50%");
|
||||
radGrad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 1);
|
||||
radGrad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0);
|
||||
});
|
||||
|
||||
// Root Group for Zoom
|
||||
rootGroup = svgElement.append("g").attr("class", "zoom-containment");
|
||||
|
||||
@@ -135,21 +261,33 @@ export function updateData(data) {
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitize links to filter out any references to non-existent nodes
|
||||
const nodeIds = new Set(data.nodes.map(n => n.id));
|
||||
const validLinks = (data.links || []).filter(l => {
|
||||
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
return nodeIds.has(srcId) && nodeIds.has(tgtId);
|
||||
});
|
||||
|
||||
// Update Links
|
||||
link = rootGroup.select(".links-layer")
|
||||
.selectAll("path")
|
||||
.data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
|
||||
.data(validLinks, d => {
|
||||
const srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
||||
const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
||||
return srcId + "-" + tgtId + "-" + d.type;
|
||||
})
|
||||
.join(
|
||||
enter => enter.append("path")
|
||||
.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)';
|
||||
if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)';
|
||||
if (d.type === 'Next' || d.type === 'relates_to') return 'rgba(255,255,255,0.2)';
|
||||
if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)';
|
||||
return 'rgba(255,255,255,0.1)';
|
||||
})
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
|
||||
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
|
||||
.attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1)
|
||||
.attr("stroke-dasharray", d => d.type === 'References' ? "5,5" : "0")
|
||||
.style("opacity", 0)
|
||||
.call(enter => enter.transition().duration(500).style("opacity", 1)),
|
||||
update => update,
|
||||
@@ -174,13 +312,8 @@ export function updateData(data) {
|
||||
|
||||
g.append("circle")
|
||||
.attr("r", 30)
|
||||
.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", d => d.group === 'current' ? 0.6 : 0.2);
|
||||
.attr("fill", d => `url(#nebulaGlow-${getCategoryStyle(d).glowKey})`)
|
||||
.attr("opacity", d => getCategoryStyle(d).opacity);
|
||||
|
||||
g.append("rect")
|
||||
.attr("class", "node-pill")
|
||||
@@ -189,23 +322,20 @@ export function updateData(data) {
|
||||
.attr("width", d => getPillWidth(d))
|
||||
.attr("height", 30)
|
||||
.attr("rx", 15)
|
||||
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
||||
.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);
|
||||
.attr("fill", "rgba(20, 20, 20, 0.95)")
|
||||
.attr("stroke", d => getCategoryStyle(d).color)
|
||||
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
|
||||
|
||||
g.append("text")
|
||||
.text(d => getDisplayLabel(d))
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 5)
|
||||
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
|
||||
.attr("font-size", "0.8rem");
|
||||
.attr("fill", d => getCategoryStyle(d).textColor)
|
||||
.attr("font-size", "0.8rem")
|
||||
.attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
|
||||
|
||||
g.append("title")
|
||||
.text(d => d.label);
|
||||
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
|
||||
|
||||
g.transition().duration(500).style("opacity", 1);
|
||||
|
||||
@@ -216,7 +346,7 @@ export function updateData(data) {
|
||||
);
|
||||
|
||||
simulation.nodes(data.nodes);
|
||||
simulation.force("link").links(data.links);
|
||||
simulation.force("link").links(validLinks);
|
||||
simulation.alpha(0.5).restart();
|
||||
|
||||
// Trigger zoom to fit after a short delay to allow simulation to settle
|
||||
@@ -398,6 +528,15 @@ export function clear() {
|
||||
}
|
||||
simulation.nodes([]);
|
||||
}
|
||||
|
||||
// Reset selections
|
||||
link = null;
|
||||
node = null;
|
||||
|
||||
// Reset D3 zoom transform to clean identity state
|
||||
if (svgElement && zoomBehavior) {
|
||||
svgElement.call(zoomBehavior.transform, d3.zoomIdentity);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to clear force simulation safely:", e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user