feat: Release v1.2.0 - Concepts Map, RAG Search & Core Consolidations #59

Open
Antigravity wants to merge 33 commits from develop into main
11 changed files with 144 additions and 47 deletions
Showing only changes of commit 34794db209 - Show all commits
@@ -3,4 +3,4 @@ using MediatR;
namespace NexusReader.Application.Commands.Sync; namespace NexusReader.Application.Commands.Sync;
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>; public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest<Result>;
@@ -38,9 +38,18 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);
// Broadcast to other devices // Broadcast to other devices
var group = _hubContext.Clients.Group($"User_{request.UserId}");
if (!string.IsNullOrEmpty(request.ExcludedConnectionId))
{
await _hubContext.Clients await _hubContext.Clients
.Group($"User_{request.UserId}") .GroupExcept($"User_{request.UserId}", request.ExcludedConnectionId)
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken); .SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
}
else
{
await group.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
}
return Result.Ok(); return Result.Ok();
} }
@@ -20,7 +20,7 @@ public class SyncHub : Hub
var userId = Context.UserIdentifier; var userId = Context.UserIdentifier;
if (!string.IsNullOrEmpty(userId)) if (!string.IsNullOrEmpty(userId))
{ {
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId)); await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, Context.ConnectionId));
} }
} }
@@ -4,6 +4,8 @@ public static class PromptRegistry
{ {
public const string KnowledgeExtractionSystemPrompt = public const string KnowledgeExtractionSystemPrompt =
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " + "You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
"CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " +
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " +
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " + "CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
"Schema: { " + "Schema: { " +
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " + "\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
@@ -13,11 +15,15 @@ public static class PromptRegistry
public const string GraphExtractionPrompt = public const string GraphExtractionPrompt =
"You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " + "You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " +
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. You MUST use these exact IDs as the 'id' for the nodes representing those blocks. " + "CRITICAL: Restrict 'label' to a maximum of 3 words. " +
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points and their relationships. " +
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. Use these IDs ONLY for nodes representing the blocks. " +
"CRITICAL: All other extracted 'concept' nodes MUST have unique, slug-style IDs based on their labels (e.g., 'dependency-injection'). " +
"Include a 'current' node representing the block content itself if applicable. " + "Include a 'current' node representing the block content itself if applicable. " +
"CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " + "CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " +
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }"; "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }";
public const string SummaryAndQuizPrompt = public const string SummaryAndQuizPrompt =
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " + "You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }"; "Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
@@ -129,7 +129,7 @@
{ {
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase)) if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
{ {
QuizState.RequestQuiz(ContextBlockId); await QuizState.RequestQuiz(ContextBlockId);
} }
if (OnActionTriggered.HasDelegate) if (OnActionTriggered.HasDelegate)
@@ -38,7 +38,7 @@
<button class="toolbar-item" title="Global Settings"> <button class="toolbar-item" title="Global Settings">
<NexusIcon Name="settings" Size="20" /> <NexusIcon Name="settings" Size="20" />
</button> </button>
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Logout"> <button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
<NexusIcon Name="log-out" Size="20" /> <NexusIcon Name="log-out" Size="20" />
</button> </button>
</div> </div>
@@ -75,3 +75,22 @@
color: #ff4d4d; color: #ff4d4d;
background: rgba(255, 77, 77, 0.1); background: rgba(255, 77, 77, 0.1);
} }
.toolbar-item.logout-item {
margin-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 1.5rem;
height: auto;
width: 100%;
display: flex;
justify-content: center;
border-radius: 0;
color: #444;
}
.toolbar-item.logout-item:hover {
color: #ff4d4d;
background: none;
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.4));
}
@@ -12,28 +12,33 @@
.graph-controls { .graph-controls {
position: absolute; position: absolute;
bottom: 1rem; bottom: 1.5rem;
right: 1.5rem; right: 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.5rem; gap: 0.25rem;
background: rgba(20, 20, 20, 0.4);
backdrop-filter: blur(12px);
padding: 0.35rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10; z-index: 10;
} }
.zoom-btn { .zoom-btn {
width: 28px; width: 32px;
height: 28px; height: 32px;
background: rgba(18, 18, 18, 0.8); background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(4px); border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px;
border-radius: 4px; color: #aaa;
color: #888; font-size: 1.1rem;
font-size: 1rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s ease; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
} }
.zoom-btn:hover { .zoom-btn:hover {
@@ -33,7 +33,7 @@ main {
height: 100%; height: 100%;
background: #0d0d0d; background: #0d0d0d;
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3); box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3);
border-left: 1px solid rgba(255, 255, 255, 0.05); border-left: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden; overflow: hidden;
z-index: 10; z-index: 10;
} }
@@ -79,7 +79,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global") public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{ {
_quizService.SetHydrating(true); await _quizService.SetHydrating(true);
LogRequestingSummary(tenantId); LogRequestingSummary(tenantId);
try try
{ {
@@ -91,7 +91,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex)) .Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
.ToList(); .ToList();
_quizService.SetQuiz(null, new QuizDto(quizQuestions)); await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
await _platformService.VibrateSuccessAsync(); await _platformService.VibrateSuccessAsync();
return packet; return packet;
} }
@@ -104,7 +104,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
} }
finally finally
{ {
_quizService.SetHydrating(false); await _quizService.SetHydrating(false);
} }
return null; return null;
} }
@@ -112,7 +112,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task ClearAsync() public async Task ClearAsync()
{ {
await _graphService.Clear(); await _graphService.Clear();
_quizService.SetQuiz(null, null); await _quizService.SetQuiz(null, null);
} }
public void Dispose() public void Dispose()
@@ -1,10 +1,13 @@
import * as d3 from 'https://esm.sh/d3@7'; 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;
let simulation; let simulation;
let zoomBehavior; let zoomBehavior;
let svgElement; let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeHandler; let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
export function mount(containerId, data, dotNetHelper) { export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
@@ -18,7 +21,8 @@ export function mount(containerId, data, dotNetHelper) {
svgElement = d3.select(container).append("svg") svgElement = d3.select(container).append("svg")
.attr("viewBox", [0, 0, width, height]) .attr("viewBox", [0, 0, width, height])
.attr("width", "100%") .attr("width", "100%")
.attr("height", "100%"); .attr("height", "100%")
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
// Radial gradient for Nebula effect // Radial gradient for Nebula effect
const defs = svgElement.append("defs"); const defs = svgElement.append("defs");
@@ -66,14 +70,19 @@ export function mount(containerId, data, dotNetHelper) {
svgElement.call(zoomBehavior).on("wheel.zoom", null); svgElement.call(zoomBehavior).on("wheel.zoom", null);
resizeHandler = () => handleResize(containerId); // Use ResizeObserver for more reliable container size tracking
window.addEventListener('resize', resizeHandler); resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
handleResize(containerId);
}
});
resizeObserver.observe(container);
simulation = d3.forceSimulation() simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(120)) .force("link", d3.forceLink().id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400)) .force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2)) .force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(50)); .force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
simulation.on("tick", () => { simulation.on("tick", () => {
if (link) { if (link) {
@@ -86,7 +95,14 @@ export function mount(containerId, data, dotNetHelper) {
} }
if (node) { if (node) {
node.attr("transform", d => `translate(${d.x},${d.y})`); node.attr("transform", d => {
// Keep within bounds with padding
const pillWidth = getPillWidth(d);
const halfWidth = pillWidth / 2;
d.x = Math.max(halfWidth + 20, Math.min(width - halfWidth - 20, d.x));
d.y = Math.max(35, Math.min(height - 35, d.y));
return `translate(${d.x},${d.y})`;
});
} }
if (badge && badge.style("display") !== "none") { if (badge && badge.style("display") !== "none") {
@@ -168,11 +184,11 @@ export function updateData(data) {
g.append("rect") g.append("rect")
.attr("class", "node-pill") .attr("class", "node-pill")
.attr("x", d => -(d.label.length * 4 + 10)) .attr("x", d => -getPillWidth(d) / 2)
.attr("y", -12) .attr("y", -15)
.attr("width", d => d.label.length * 8 + 20) .attr("width", d => getPillWidth(d))
.attr("height", 24) .attr("height", 30)
.attr("rx", 12) .attr("rx", 15)
.attr("fill", "rgba(20, 20, 20, 0.9)") .attr("fill", "rgba(20, 20, 20, 0.9)")
.attr("stroke", d => { .attr("stroke", d => {
if (d.type === 'Definition') return 'var(--nexus-accent)'; if (d.type === 'Definition') return 'var(--nexus-accent)';
@@ -182,12 +198,15 @@ export function updateData(data) {
.attr("stroke-width", 1); .attr("stroke-width", 1);
g.append("text") g.append("text")
.text(d => d.label) .text(d => getDisplayLabel(d))
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.attr("y", 4) .attr("y", 5)
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc') .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
.attr("font-size", "0.8rem"); .attr("font-size", "0.8rem");
g.append("title")
.text(d => d.label);
g.transition().duration(500).style("opacity", 1); g.transition().duration(500).style("opacity", 1);
return g; return g;
@@ -199,6 +218,9 @@ export function updateData(data) {
simulation.nodes(data.nodes); simulation.nodes(data.nodes);
simulation.force("link").links(data.links); simulation.force("link").links(data.links);
simulation.alpha(0.5).restart(); simulation.alpha(0.5).restart();
// Trigger zoom to fit after a short delay to allow simulation to settle
setTimeout(zoomToFit, 100);
} }
function drag(simulation) { function drag(simulation) {
@@ -225,6 +247,7 @@ function drag(simulation) {
export function setActiveNode(nodeId) { export function setActiveNode(nodeId) {
if (!svgElement || !node) return; if (!svgElement || !node) return;
// Safety check: ensure we only target the first occurrence if IDs are duplicated
const targetNode = node.filter(d => d.id === nodeId); const targetNode = node.filter(d => d.id === nodeId);
if (targetNode.empty()) { if (targetNode.empty()) {
dimNodes(null); dimNodes(null);
@@ -232,20 +255,21 @@ export function setActiveNode(nodeId) {
return; return;
} }
const d = targetNode.datum(); const firstMatch = targetNode.filter((d, i) => i === 0);
const d = firstMatch.datum();
// Reset all active classes // Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false); rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
targetNode.select(".node-pill").classed("nexus-node-active", true); firstMatch.select(".node-pill").classed("nexus-node-active", true);
// Position badge // Position badge
badge.style("display", "block").datum(d); badge.style("display", "block").datum(d);
badge.attr("transform", `translate(${d.x},${d.y})`); badge.attr("transform", `translate(${d.x},${d.y})`);
// Dim others // Dim others (only exact matches for nodeId will be fully opaque)
dimNodes(nodeId); dimNodes(nodeId);
// Smooth transition // Smooth transition to the first matching node
svgElement.transition().duration(1000).call( svgElement.transition().duration(1000).call(
zoomBehavior.transform, zoomBehavior.transform,
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y) d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y)
@@ -274,8 +298,8 @@ export function unmount(containerId) {
if (simulation) { if (simulation) {
simulation.stop(); simulation.stop();
} }
if (resizeHandler) { if (resizeObserver) {
window.removeEventListener('resize', resizeHandler); resizeObserver.disconnect();
} }
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (container) { if (container) {
@@ -321,9 +345,43 @@ export function zoomOut() {
} }
export function zoomReset() { export function zoomReset() {
if (svgElement && zoomBehavior) { zoomToFit();
svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity);
} }
export function zoomToFit() {
if (!node || node.empty() || !svgElement || !zoomBehavior) return;
// Get the actual bounding box of the nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
node.each(d => {
const pw = getPillWidth(d) / 2;
minX = Math.min(minX, d.x - pw);
maxX = Math.max(maxX, d.x + pw);
minY = Math.min(minY, d.y - 15);
maxY = Math.max(maxY, d.y + 15);
});
if (minX === Infinity) return;
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const padding = 60;
const scale = Math.min(
(width - padding) / graphWidth,
(height - padding) / graphHeight,
1.2 // Max scale
);
svgElement.transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-midX, -midY)
);
} }
export function clear() { export function clear() {