Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ff8d81d0f | |||
| 62c8d8a687 | |||
| 6f1cdfe125 | |||
| dedcf0231c |
@@ -3,4 +3,4 @@ using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Commands.Sync;
|
||||
|
||||
public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest<Result>;
|
||||
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>;
|
||||
|
||||
@@ -38,18 +38,9 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices
|
||||
var group = _hubContext.Clients.Group($"User_{request.UserId}");
|
||||
|
||||
if (!string.IsNullOrEmpty(request.ExcludedConnectionId))
|
||||
{
|
||||
await _hubContext.Clients
|
||||
.GroupExcept($"User_{request.UserId}", request.ExcludedConnectionId)
|
||||
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await group.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||
}
|
||||
await _hubContext.Clients
|
||||
.Group($"User_{request.UserId}")
|
||||
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public class SyncHub : Hub
|
||||
var userId = Context.UserIdentifier;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, Context.ConnectionId));
|
||||
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ public static class PromptRegistry
|
||||
{
|
||||
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. " +
|
||||
"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. " +
|
||||
"Schema: { " +
|
||||
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
|
||||
@@ -15,15 +13,11 @@ public static class PromptRegistry
|
||||
|
||||
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. " +
|
||||
"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'). " +
|
||||
"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. " +
|
||||
"Include a 'current' node representing the block content itself if applicable. " +
|
||||
"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 } ] } }";
|
||||
|
||||
|
||||
public const string SummaryAndQuizPrompt =
|
||||
"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 } ] }";
|
||||
|
||||
@@ -46,9 +46,6 @@
|
||||
case "arrow-right":
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
break;
|
||||
case "log-out":
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
break;
|
||||
default:
|
||||
<!-- Fallback circle -->
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -129,7 +129,7 @@
|
||||
{
|
||||
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await QuizState.RequestQuiz(ContextBlockId);
|
||||
QuizState.RequestQuiz(ContextBlockId);
|
||||
}
|
||||
|
||||
if (OnActionTriggered.HasDelegate)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<aside class="intelligence-toolbar">
|
||||
<div class="toolbar-top">
|
||||
@@ -38,9 +36,6 @@
|
||||
<button class="toolbar-item" title="Global Settings">
|
||||
<NexusIcon Name="settings" Size="20" />
|
||||
</button>
|
||||
<button class="toolbar-item logout-item" @onclick="HandleLogout" title="Exit">
|
||||
<NexusIcon Name="log-out" Size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -61,12 +56,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogout()
|
||||
{
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/", true);
|
||||
}
|
||||
|
||||
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.intelligence-toolbar {
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
background: #0D0D0D;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #080808;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -10,7 +10,6 @@
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
box-shadow: inset -2px 0 10px rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +22,7 @@
|
||||
.toolbar-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #555;
|
||||
color: #444;
|
||||
cursor: pointer;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -38,15 +37,11 @@
|
||||
.toolbar-item:hover {
|
||||
color: var(--nexus-neon);
|
||||
background: rgba(0, 255, 153, 0.05);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15);
|
||||
filter: drop-shadow(0 0 5px var(--nexus-neon));
|
||||
}
|
||||
|
||||
.toolbar-item.active {
|
||||
color: var(--nexus-neon);
|
||||
background: rgba(0, 255, 153, 0.08);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 153, 0.25);
|
||||
filter: drop-shadow(0 0 8px var(--nexus-neon));
|
||||
}
|
||||
|
||||
.toolbar-item.active::after {
|
||||
@@ -75,22 +70,3 @@
|
||||
color: #ff4d4d;
|
||||
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,33 +12,28 @@
|
||||
|
||||
.graph-controls {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
bottom: 1rem;
|
||||
right: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
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);
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(18, 18, 18, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
|
||||
@@ -35,7 +35,10 @@
|
||||
<span>Asystent AI</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="user-profile">
|
||||
<span class="user-email">@context.User.Identity?.Name</span>
|
||||
<button class="logout-btn" @onclick="HandleLogout">Logout</button>
|
||||
</div>
|
||||
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
@@ -90,7 +93,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task HandleLogout()
|
||||
{
|
||||
await IdentityService.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/", true);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ main {
|
||||
height: 100%;
|
||||
background: #0d0d0d;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
|
||||
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
||||
{
|
||||
await _quizService.SetHydrating(true);
|
||||
_quizService.SetHydrating(true);
|
||||
LogRequestingSummary(tenantId);
|
||||
try
|
||||
{
|
||||
@@ -91,7 +91,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
|
||||
.ToList();
|
||||
|
||||
await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
|
||||
_quizService.SetQuiz(null, new QuizDto(quizQuestions));
|
||||
await _platformService.VibrateSuccessAsync();
|
||||
return packet;
|
||||
}
|
||||
@@ -104,7 +104,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _quizService.SetHydrating(false);
|
||||
_quizService.SetHydrating(false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -112,7 +112,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
public async Task ClearAsync()
|
||||
{
|
||||
await _graphService.Clear();
|
||||
await _quizService.SetQuiz(null, null);
|
||||
_quizService.SetQuiz(null, null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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 zoomBehavior;
|
||||
let svgElement;
|
||||
|
||||
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
|
||||
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeHandler;
|
||||
|
||||
export function mount(containerId, data, dotNetHelper) {
|
||||
const container = document.getElementById(containerId);
|
||||
@@ -21,8 +18,7 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
svgElement = d3.select(container).append("svg")
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
|
||||
.attr("height", "100%");
|
||||
|
||||
// Radial gradient for Nebula effect
|
||||
const defs = svgElement.append("defs");
|
||||
@@ -70,19 +66,14 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
|
||||
svgElement.call(zoomBehavior).on("wheel.zoom", null);
|
||||
|
||||
// Use ResizeObserver for more reliable container size tracking
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
handleResize(containerId);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
resizeHandler = () => handleResize(containerId);
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
simulation = d3.forceSimulation()
|
||||
.force("link", d3.forceLink().id(d => d.id).distance(120))
|
||||
.force("charge", d3.forceManyBody().strength(-400))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
|
||||
.force("collide", d3.forceCollide().radius(50));
|
||||
|
||||
simulation.on("tick", () => {
|
||||
if (link) {
|
||||
@@ -95,14 +86,7 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
}
|
||||
|
||||
if (node) {
|
||||
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})`;
|
||||
});
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
|
||||
if (badge && badge.style("display") !== "none") {
|
||||
@@ -184,11 +168,11 @@ export function updateData(data) {
|
||||
|
||||
g.append("rect")
|
||||
.attr("class", "node-pill")
|
||||
.attr("x", d => -getPillWidth(d) / 2)
|
||||
.attr("y", -15)
|
||||
.attr("width", d => getPillWidth(d))
|
||||
.attr("height", 30)
|
||||
.attr("rx", 15)
|
||||
.attr("x", d => -(d.label.length * 4 + 10))
|
||||
.attr("y", -12)
|
||||
.attr("width", d => d.label.length * 8 + 20)
|
||||
.attr("height", 24)
|
||||
.attr("rx", 12)
|
||||
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
||||
.attr("stroke", d => {
|
||||
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
||||
@@ -198,14 +182,11 @@ export function updateData(data) {
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
g.append("text")
|
||||
.text(d => getDisplayLabel(d))
|
||||
.text(d => d.label)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 5)
|
||||
.attr("y", 4)
|
||||
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
|
||||
.attr("font-size", "0.8rem");
|
||||
|
||||
g.append("title")
|
||||
.text(d => d.label);
|
||||
|
||||
g.transition().duration(500).style("opacity", 1);
|
||||
|
||||
@@ -218,9 +199,6 @@ export function updateData(data) {
|
||||
simulation.nodes(data.nodes);
|
||||
simulation.force("link").links(data.links);
|
||||
simulation.alpha(0.5).restart();
|
||||
|
||||
// Trigger zoom to fit after a short delay to allow simulation to settle
|
||||
setTimeout(zoomToFit, 100);
|
||||
}
|
||||
|
||||
function drag(simulation) {
|
||||
@@ -247,7 +225,6 @@ function drag(simulation) {
|
||||
export function setActiveNode(nodeId) {
|
||||
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);
|
||||
if (targetNode.empty()) {
|
||||
dimNodes(null);
|
||||
@@ -255,21 +232,20 @@ export function setActiveNode(nodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstMatch = targetNode.filter((d, i) => i === 0);
|
||||
const d = firstMatch.datum();
|
||||
const d = targetNode.datum();
|
||||
|
||||
// Reset all active classes
|
||||
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
|
||||
firstMatch.select(".node-pill").classed("nexus-node-active", true);
|
||||
targetNode.select(".node-pill").classed("nexus-node-active", true);
|
||||
|
||||
// Position badge
|
||||
badge.style("display", "block").datum(d);
|
||||
badge.attr("transform", `translate(${d.x},${d.y})`);
|
||||
|
||||
// Dim others (only exact matches for nodeId will be fully opaque)
|
||||
// Dim others
|
||||
dimNodes(nodeId);
|
||||
|
||||
// Smooth transition to the first matching node
|
||||
// Smooth transition
|
||||
svgElement.transition().duration(1000).call(
|
||||
zoomBehavior.transform,
|
||||
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y)
|
||||
@@ -298,8 +274,8 @@ export function unmount(containerId) {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
if (resizeHandler) {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
}
|
||||
const container = document.getElementById(containerId);
|
||||
if (container) {
|
||||
@@ -345,43 +321,9 @@ export function zoomOut() {
|
||||
}
|
||||
|
||||
export function zoomReset() {
|
||||
zoomToFit();
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
if (svgElement && zoomBehavior) {
|
||||
svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
|
||||
Reference in New Issue
Block a user