13 Commits

Author SHA1 Message Date
Antigravity c94e8f0acb feat(creator): overhaul Creator flow, editor duplication, and staging setup (#83)
This pull request completely overhauls the Creator editor flow, resolves the editor duplication race condition, aligns layout/styling themes in light and dark mode, and adds Docker staging setups.

### Key Changes
1. **Creator Flow Polish**: Redesigned the editor canvas to prevent double scrolling by delegating overflow to the editor canvas layer, updated styles to a premium aesthetic.
2. **Race Condition Prevention**: Resolved Crepe editor duplication when loading or switching chapters by tracking state via shared window maps (`window.editorCache`, `window.editorStates`) and checking `_lastInitializedEditorId` synchronously in Blazor.
3. **Theme Synchronization**: Integrated explicit theme initialization (`ThemeService.InitializeAsync()`) and anchored CSS isolation selectors to correctly sync with Light (Soft Sepia) and Deep Dark theme preferences.
4. **Staging Automation**: Created staging docker configurations with `--nexus-only` flag to allow iterative development without resetting PG/Neo4j database containers.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #83
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-15 17:15:42 +00:00
Antigravity ec3fc52a73 feat(editor): align selection popup and all editor control elements styling with Reader (#81)
## Summary of Changes
This pull request aligns all major interactive editor control elements in the Milkdown Crepe editor with the premium `SelectionAiPanel` / `IntelligenceToolbar` glassmorphism design.

### Changes:
1. **Selection Bubble Menu Unification:** Relocated the selection menu overrides from `Creator.razor.css` to `app.css` to resolve scoping bugs. Themed to match the Reader's selection popup 1:1.
2. **Editor Controls Theming:** Themed table cell drag handles, table actions popups, line insertion handles & add buttons, Notion-style paragraph drag handles, and slash commands menus with glassmorphic backgrounds, perimeter borders, hover transitions, and active accent states.
3. **Visibility Lifecycle Fixes:** Excluded `.cell-handle` and `.milkdown-block-handle` from explicit `display: none !important` rules when hidden, preserving their dimensions for correct JS positioning calculations and preventing handles from jumping/sliding.
4. **Table Margin Clipping Fix:** Set `overflow: visible !important` on `.tableWrapper` to allow table controls to draw cleanly into the editor canvas's padding zone without boundary clipping.

Resolves #82.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #81
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-11 18:07:51 +00:00
Antigravity 9fddafa423 feat: implement dashboard mobile responsiveness v3 overhaul (#80)
This pull request implements **Mobile Responsiveness (v3) and a High-Fidelity Overhaul** for the dashboard view layer (`/dashboard`, `/catalog`, `/my-books`, `/profile`), matching the design aesthetics of the production mobile e-reader layout.

### Key Changes
1. **Re-engineered Floating Navigation Dock Capsule:**
   - Detached bottom capsule, floating 16px off the bottom with rounded `30px` borders.
   - Glassmorphic look in dark mode (`rgba(26, 26, 30, 0.75)`) and sepia theme in light mode (`rgba(244, 241, 234, 0.9)`).
   - Removed `translateY` offset on the central "AI" action button (robot icon inside a solid green circle).
   - Used `::deep` overrides to clean up text colors and icons, preventing browser visited link purple color defaults on `NavLink` items.
   - Restored compact standalone text labels under the navigation icons.
2. **Dashboard Layout Scale Compression & Fold Optimization:**
   - Compressed profile header avatar (`40px`), welcome title font size, and status pill spacing.
   - Compressed the `concepts-linear-stack` height to `120px` with scrolling.
   - Reduced book cover heights inside `MyBooks` to `200px` on mobile viewports.
3. **Current Reading Widget & Layout Margin Safety:**
   - Localized the widget button label to `"Kontynuuj czytanie"`.
   - Used safe area clearances (`calc(1.5rem + 72px + env(safe-area-inset-bottom))`) at the bottom of pages to prevent content from being covered by the capsule.
4. **Resolved Horizontal Layout Width Blowout (Grid Fix):**
   - Discovered that `.secondary-grid` was using flex layout inherited from desktop rather than grid layout on mobile.
   - Nowrap paragraphs in the concept list stretched the flex container to over 12,000 pixels wide, breaking the entire dashboard layout width.
   - Overrode `.secondary-grid` to `display: grid !important` on mobile, properly constraining layout width and allowing nowrap concept text truncation.

### Verification
- All code changes compiled successfully under `dotnet build NexusReader.slnx --no-restore` (0 errors).
- Validated correct layouts in list view, chart view, and light/dark theme toggles on a Samsung Galaxy S20 Ultra (412x915) viewport.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #80
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-08 11:05:57 +00:00
Antigravity 9291bde531 style: complete Light Sepia theme overrides for user dashboard (#78)
Resolves #79

This Pull Request completes the visual implementation of the premium **"Warm Paper / Soft Sepia"** light theme across all user dashboard sub-modules and components.

### 🎨 Styling Refactoring & Light Sepia Layout
We refactored isolated component styles (`.razor.css`) to ensure proper contrast, remove hardcoded dark tokens, and fix text visibility under the `.theme-light` environment:

1. **Dashboard & Sidebar Layouts:**
   * **MainHubLayout.razor.css** & **Dashboard.razor.css**: Replaced hardcoded dark backgrounds with `var(--bg-surface)` and `var(--bg-base)`. Overrode user name brackets, progress bar elements, active quiz cards, graph nodes, and buttons.
2. **Catalog & Library Pages:**
   * **Catalog.razor.css** & **MyBooks.razor.css**: Adjusted cover hover actions, overlay transparency, and progress tracks. Fixed course tile background gradients to use a warm, elegant `#e4e1d9` layer and `var(--text-main)` code text.
3. **Profile & Settings Views:**
   * **Profile.razor.css** & **Settings.razor.css**: Overrode token usage progress tracks, page title gradient transparency, section descriptions, and diagnostic button styling.
4. **Concepts Dashboard & Interactive Widgets:**
   * **ConceptsDashboard.razor.css** & **ConceptsMap.razor.css**: Transitioned node headers, unlocked/locked badges, warning blocks, and term pills to semantic colors. Removed neon glow animations for locked nodes.
5. **Intelligence Workspace & AI Responses:**
   * **Intelligence.razor.css** & **AiResponseRenderer.razor.css**: Refactored empty state welcome messages, placeholder styles, input fields, and robot avatar borders. Refined the linear-gradient mask fade effect to blend correctly into the light sepia surface environment rather than dropping into dark transparent channels.
6. **Dashboard Sidebar Widgets:**
   * **CurrentReadingWidget.razor.css** & **ContextualRecommendationsWidget.razor.css**: Replaced hardcoded `#1a1a1e` card containers and light text colors with semantic variables (`var(--bg-surface)`, `var(--border)`, `var(--text-main)`, and `var(--text-muted)`).

### 🛠️ Blazor CSS Isolation Compiler Compliance
* Avoided the use of `:global(.theme-light)` selector overrides within isolated CSS rules because they are unsupported by Blazor's CSS isolation compiler and cause the compiled stylesheet to ignore them.
* Replaced them with the correct standard selector format `.theme-light .some-class` which properly compiles to `.theme-light .some-class[b-xxxx]`, applying correct theme styles recursively to child scoped markup.

### 🧪 Verification
* Checked that the solution builds cleanly with 0 compiler errors: `dotnet build NexusReader.slnx --no-restore`
* Ran all unit tests successfully: `dotnet test NexusReader.slnx --no-restore`

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #78
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-07 16:56:36 +00:00
Antigravity 1d6862016d feat(recommendations): implement contextual recommendation engine (#76)
Resolves #75

### Description
This pull request implements a smart, Native AOT-compliant contextual recommendation engine for the desktop dashboard to drive user retention and cross-book monetization.

### Key Changes
1. **Application Layer**:
   - Declared `IUserReadingStateStore` interface to decouple reading state discovery.
   - Added `IVectorSearchStore.SearchGlobalExcludeAsync(...)` to abstract semantic similarity searches with exclusions.
   - Defined `GetContextualRecommendationsQuery` and response DTOs (`ContextualRecommendationResponse`, `RecommendationDto`).
2. **Infrastructure Layer**:
   - Implemented `UserReadingStateStore` using EF Core with DbContext pooling.
   - Implemented `SearchGlobalExcludeAsync` in `VectorSearchStore` to construct gRPC Qdrant filters (excluding the active book ID) and fetch `bookTitle` and `chapterTitle` from point payloads.
   - Implemented `GetContextualRecommendationsQueryHandler` using clean abstractions.
3. **Web & Serialization Layer**:
   - Mapped `GET /api/recommendations` endpoint.
   - Registered types in `AppJsonContext.cs` for AOT-compliant JSON serialization.
4. **Verification**:
   - Added complete unit test coverage in `GetContextualRecommendationsQueryTests.cs`. All 30 unit tests pass.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #76
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-06 13:38:48 +00:00
Antigravity bcd5daa3a0 feat: Refactor dashboard screens to Modern Deep Dark style (#74)
Refactored Pulpit, Katalog, Moje, Konto screens to a unified, premium Modern Deep Dark style.

Resolves #73.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #74
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-06 07:58:59 +00:00
Antigravity f6819d50b7 feat(mobile-ux): implement theme toggle, client-side persistence, and light mode style overrides (#71)
Resolves #72

## Description

This PR implements the theme toggle mechanism, client-side persistence, and comprehensive light mode style overrides for the mobile reader layout in **NexusReader**.

### Key Changes
1. **ThemeService & State Management**:
   - Enhanced `ThemeService` with `InitializeAsync` and `ToggleTheme` using JS Interop.
   - Synchronized theme state changes via `OnThemeChanged` event.
2. **Local Storage Persistence & FOUC Prevention**:
   - Added client-side script in `App.razor` and MAUI `index.html` to instantly apply `.theme-light` from `localStorage` before prerendering/rendering to prevent a Flash of Unthemed Content (FOUC).
   - Created `theme.js` containing JS helper methods (`themeInterop.isLightMode`, `themeInterop.setLightMode`) to interface with `localStorage` and `document.documentElement` class list.
3. **UI Theme Toggle**:
   - Added a minimalist glassmorphic theme toggle button in the header of the `ReaderCanvas.razor` with dynamic transitions and icon morphing between sun and moon SVG icons.
4. **Light Mode Stylesheets Overrides**:
   - Added detailed light mode scoped styles (`.theme-light`) for the `ReaderCanvas` and the bottom-sheet `GlobalIntelligence` AI chat panel, utilizing earthy gray text (`#2d2a26`) on warm paper-like backgrounds (`rgba(244, 241, 234, 0.95)` / `#faf8f5`) to maintain high-quality aesthetic consistency.

### Verification
- Solution built successfully without errors (`dotnet build NexusReader.slnx --no-restore`).
- Scoped CSS isolation checked and validated for both Web and MAUI contexts.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #71
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-05 18:02:33 +00:00
Antigravity f18663426b style(ui): refactor reader layout grid, fix focus mode layout collapse, fix SVG rendering dots, reorganize intelligence toolbar (#69)
Reorganized the reader toolbar and layout grid to improve visual consistency and layout robustness in Focus Mode. Fixed outline SVG rendering bugs that caused icons to show as solid dots.

Closes #70

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #69
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-05 09:51:29 +00:00
Antigravity 081c6f7940 fix(infra): use bash socket healthcheck for qdrant in staging and test (#68)
This Pull Request fixes the Qdrant startup error on the Staging and Testing environments.

### 🔍 Cause of the Bug
The official `qdrant/qdrant:latest` image is built on `debian-slim` and **does not contain `curl` or `wget`**. Changing the healthcheck to `curl` caused Qdrant to exit with status `127` (command not found), marking the service as unhealthy/error in Docker.

### 🛠️ Solution
Reverts the healthcheck in both `docker-compose.stage.yml` and `docker-compose.test.yml` to the robust, built-in bash TCP socket check:
```yaml
    healthcheck:
      test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
```

Successfully validated locally and tested compilation.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #68
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-01 17:36:03 +00:00
Antigravity 00004ce433 feat(infra): create staging docker-compose and environment configuration (#67)
This pull request introduces a production-grade, security-hardened Docker Staging environment configuration for **NexusReader**, prepared directly from the `develop` branch.

### 🚀 Key Additions

1. **`docker-compose.stage.yml`**:
   - Deploys five isolated containers (`nexus-web-stage`, `nexus-db-stage`, `nexus-qdrant-stage`, `nexus-neo4j-stage`) inside a dedicated `nexus-stage` bridge network.
   - Sets non-conflicting port mappings to allow staging to run concurrently with other environments on the same host (e.g., Web on `5080`, Postgres on `5438`, Neo4j HTTP on `7488`).
   - Configures robust container healthchecks (`curl` for Qdrant, `wget` for Neo4j, `pg_isready` for Postgres).
   - Maps dedicated named persistent volumes for databases (`pgdata_stage`, `qdrant_stage_data`, `neo4j_stage_data`) to prevent data loss.
   - Maps separate persistent volumes specifically for dynamic web uploads (`stage_www_uploads` for EPUBs, `stage_www_covers` for covers) without overriding the compiled static web client files.

2. **`.env.stage.template`**:
   - A clean deployment environment template providing a blueprint of all variables.
   - Copied to `.env.stage` locally during deployment to inject secrets securely.
   - Mandates a secure `NEXUS_ADMIN_PASSWORD` (checked by `DbInitializer` for staging/production builds).

3. **`.gitignore`**:
   - Explicitly ignores local environment configurations (such as `.env.stage`) to prevent accidentally committing credentials, while keeping the `.env.stage.template` tracked.

---

### 🧪 Verification Performed

- **Docker Compose Validation**: Ran `docker compose -f docker-compose.stage.yml --env-file .env.stage config` successfully with zero configuration or parsing errors.
- **Solution Compilation**: Ran `dotnet build NexusReader.slnx --no-restore` from root — **SUCCESS** with `0` compile errors.
- **Automated Tests**: Ran `dotnet test --no-restore` — **SUCCESS** (all 20/20 unit tests passed).

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #67
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-01 17:27:34 +00:00
Antigravity 711480f8f6 feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment (#56)
This pull request introduces the dedicated containerized infrastructure and configuration for deploying NexusReader's beta version in the Test environment.

### Summary of Changes

1. **Docker Infrastructure & Secrets**:
   - **`docker-compose.test.yml`**: Configured dedicated database and auxiliary services (PostgreSQL 17, Qdrant, Neo4j) on isolated, non-standard ports to ensure zero conflict with the existing server configurations.
   - **`.env.test.template`**: Provided an environment variable template showing required setups, including mandatory database passwords, API keys, and admin custom passwords.
   - **`.gitignore`**: Excluded local `.env` files to prevent accidental commits of production or staging secrets.

2. **Database Hardening**:
   - Configured Neo4j with basic authentication (`IDriver` instantiation uses basic auth when credentials are provided in configuration).
   - Configured PostgreSQL to use mandatory authentication.
   - Configured the admin seeder (`DbInitializer.cs`) to dynamically use `NEXUS_ADMIN_PASSWORD` from environment variables, falling back to a default password in local Development only.

3. **Feature-Flagged Restrictions**:
   - **`appsettings.Test.json`**: Implemented `Features:AllowRegistration` and `Features:AllowPasswordReset` flags set to `false`.
   - **Middleware Enforcement (`Program.cs`)**: Intercepts requests to `/identity/register` and `/identity/forgotPassword` (and their MVC/form variations) and rejects them with a `403 Forbidden` response in restricted environments.
   - **OAuth Provisioning Guard (`Program.cs`)**: Blocks new account provisioning via Google OAuth callback by checking the `Features:AllowRegistration` configuration, redirecting users to the login page with a descriptive error.
   - **UI Protection (`Login.razor`, `Register.razor`)**: Conditionally hides registration/password reset links and intercepts manual navigation attempts to `/account/register` by redirecting to login with a warning.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #56
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-01 17:17:45 +00:00
Antigravity 72905aa119 feat(ui): implement premium gamified Concepts Map dashboard, unify design tokens, and enforce scoped CSS (#54)
Resolves #55

# gamified Concepts Map Dashboard, Architecture Cleanup, & CSS Unification

This Pull Request completes the gamified **Concepts Map** interactive experience and executes a strict visual and architectural clean-up of the **NexusReader SaaS** platform by consolidating styling around the centralized **Nexus Neon** design system, enforcing Native AOT-compliant scoped-CSS, and resolving style drift.

---

### 🚀 Key Implementations

#### 1. Gamified Interactive Concepts Map Dashboard
* **Dynamic Skill-Tree Visualizer:** Implemented a gamified node-based interactive tree that reads concept connections dynamically, rendering progress nodes, unlocked/locked states, and active visual connection lines.
* **Premium UX Details:** Integrated high-fidelity hover effects, detailed sidebar popovers showing unlocked stats/summaries, and smooth parallax backdrops.
* **Secure Multi-Tenant Gating:** Hardened data queries using explicit `TenantId` gating to ensure complete layout isolation between system tenants.

#### 2. Architecture & Service Optimization
* **Service Abstraction (`IConceptsMapService`):** Introduced a clean service interface decoupled from the UI layers.
* **Polyglot Fallback Implementations:**
  - **WasmConceptsMapService:** Implements efficient client-side fetching with error recovery/loading states.
  - **ServerConceptsMapService:** Handles deep database graph mapping, relationship resolution, and multi-tenant checks.
* **CQRS Integrity:** Enforced the CQRS Result Pattern by using `MediatR` handlers to fetch data structures instead of placing database queries in UI modules.

#### 3. Nexus Neon CSS Unification & Zero-Style-Tag Standards
* **Central Design Tokens:** Solidified typography (`Inter` / `Merriweather`), primary brand green hues (`var(--nexus-neon)` at `#00ff99`), and glow variables inside the global `app.css`.
* **Zero Inline Style Tags:** Standardized all dashboard modules by completely eliminating inline `<style>` blocks in favor of scoped `.razor.css` companion files.
* **Consolidated Buttons & Glass Panels:**
  - Standardized `.btn-nexus`, `.btn-nexus-primary`, and `.btn-nexus-secondary` throughout header/footer/card operations.
  - Removed duplicate `.glass-panel` background, blur, and support-declaration overrides, making components cleanly inherit standard global styles.
* **Eliminated Brand Splitting:** Resolved legacy purple-indigo user avatar and conversation bubble gradients inside the AI Intelligence Hub (`Intelligence.razor.css`), migrating them to premium glassmorphic surfaces (`rgba(255, 255, 255, 0.05)`) contrasting beautifully against the emerald green AI theme.

---

### 🧪 Verification & Build Gate Status

* **Successful Compilation Check:** Run `dotnet build NexusReader.slnx --no-restore` from the workspace root. Flawlessly succeeded with **zero compilation errors** (`Liczba błędów: 0`) under target `.NET 10`.
* **Verified Skills Synchrony:** The companion design guidelines skill (`nexus-ui-engine/SKILL.md`) has been fully updated to secure these layout and styling conventions for future dashboard features.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #54
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-26 17:46:56 +00:00
Antigravity a0bf6c15f4 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>
2026-05-26 12:15:28 +00:00
229 changed files with 28098 additions and 2068 deletions
+11 -1
View File
@@ -30,4 +30,14 @@ When conducting or receiving a code review for NexusReader, ensure the implement
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`. - [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
## 6. Code Review Comments ## 6. Code Review Comments
- [ ] **Specific Linking**: Comments should be linked to specific code. Try to avoid general comments about the entire pull request.
### 6.1 Posting Comments
- [ ] **Code-Linked Comments**: Every review comment **must** be anchored to a specific file and line range using the Gitea inline comment API (`path` + `new_line_num`/`old_line_num`). Free-floating general comments are only acceptable for summary notes that cannot be attributed to a single location.
- [ ] **Severity Prefix**: Prefix each comment with its severity so the author can prioritize: `🔴 Blocking`, `🟡 Design/Architecture`, or `🟢 Minor/Suggestion`.
- [ ] **Actionable Guidance**: Each comment must include a concrete, actionable suggestion — not just a description of the problem. Where applicable, provide a corrected code snippet.
### 6.2 Resolving Comments (Author Responsibility)
- [ ] **Reply Before Resolving**: When a review comment has been addressed, the author **must** reply to the specific thread explaining *how* the issue was resolved (e.g., commit SHA, approach taken, or a reasoned rejection with justification). Do not close a thread without a reply.
- [ ] **Link to Fix**: If the resolution is a code change, include the commit SHA or a reference to the changed line in the reply (e.g., `Fixed in abc1234 — moved the guard before CTS allocation`).
- [ ] **Close Only After Reply**: Mark a thread as **Resolved** only after posting the reply. A thread with no reply must remain open, even if the underlying code has changed.
- [ ] **Rejection Must Be Justified**: If the author disagrees with a comment and chooses not to act on it, they must reply with a clear technical justification. The reviewer then decides whether to accept the reasoning and close the thread, or escalate it.
+14 -3
View File
@@ -12,14 +12,22 @@ description: Design System & Component rules for Blazor
- **Styling & Isolation:** - **Styling & Isolation:**
- Mandatory use of scoped CSS (`.razor.css`). - Mandatory use of scoped CSS (`.razor.css`).
- Strict compliance: Zero inline `<style>` tags are allowed in `.razor` files.
- No global CSS except for design tokens in `app.css`. - No global CSS except for design tokens in `app.css`.
- Use `::deep` only when absolutely necessary to style child components. - Use `::deep` only when absolutely necessary to style child components.
- **Design System (Nexus Neon):** - **Design System (Nexus Neon):**
- **Color Palette:** - **Color Palette:**
- Primary Accent: `--nexus-neon` (`#00ff99`) - Used for borders, highlights, and icons. - Primary Accent: `--nexus-neon` (`#00ff99`) - Used for borders, highlights, and icons.
- Neon Glow: `--nexus-neon-glow` / `--nexus-primary-glow` (`rgba(0, 255, 153, 0.3)`).
- Dark Mode: `--nexus-bg` (`#0a0a0a`), `--nexus-card` (`#141414`). - Dark Mode: `--nexus-bg` (`#0a0a0a`), `--nexus-card` (`#141414`).
- Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`). - Light Mode: `--nexus-bg` (`#f8f9fa`), `--nexus-card` (`#ffffff`).
- **No Brand Splitting:** Strict ban on custom purple/indigo/cyan elements or hardcoded accent colors (like `#7c3aed`, `#4c1d95`, `#00b3ff`) on dashboard pages. Neutral/glass surfaces (`rgba(255, 255, 255, 0.05)`) must be used for secondary elements to preserve contrast and ensure the AI/neon-green elements are the focal point.
- **Buttons:**
- Must inherit from the global `.btn-nexus` class in `app.css`.
- Primary Button: `.btn-nexus-primary` (background: `var(--nexus-neon)`, text color: `#000`).
- Secondary Button: `.btn-nexus-secondary` (background: `rgba(255, 255, 255, 0.05)`, border: `1px solid rgba(255, 255, 255, 0.1)`, text color: `#fff`).
- Hover Interaction: `transform: translateY(-2px)`, increased brightness, and a signature primary neon glow shadow.
- **Typography:** - **Typography:**
- UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels. - UI Elements: `Inter` (Sans-Serif) for controls, menus, and labels.
- Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability. - Reading Content: `Merriweather` (Serif) with `line-height: 1.65` and `letter-spacing: -0.01em` for high readability.
@@ -44,6 +52,9 @@ description: Design System & Component rules for Blazor
- **Interactive Flow:** - **Interactive Flow:**
- AI Assistant interactions must be non-blocking and smoothly transition using CSS animations. - AI Assistant interactions must be non-blocking and smoothly transition using CSS animations.
- Interactive elements must have clear `:hover`, `:active`, and `:focus` states. - Interactive elements must have clear `:hover`, `:active`, and `:focus` states.
- **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following interaction signature: - **Glass Panel Standard:** All primary data panels (`.glass-panel`) must implement the following global parameters from `app.css` (only local modifiers like padding and hover offsets should live in scoped CSS):
- `transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)` - Background: `rgba(20, 20, 20, 0.85)` (fallback) / `rgba(255, 255, 255, 0.03)` with `backdrop-filter: blur(10px)` when supported.
- `:hover` state must include: `transform: translateY(-4px)`, increased background opacity, and a subtle `--nexus-neon` border highlight (e.g., `rgba(0, 255, 153, 0.2)`). - Border: `1px solid rgba(255, 255, 255, 0.05)`.
- Border Radius: `var(--radius-xl)`.
- Transition: `all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`.
- `:hover` state: `transform: translateY(-4px)` (or local offset) and highlight accent `border-color: rgba(0, 255, 153, 0.2)`.
+42
View File
@@ -0,0 +1,42 @@
# ===================================================================
# NexusReader — Staging (Stage) Environment Variables
# ===================================================================
# Copy this file to `.env.stage` and fill in the values before deployment:
# cp .env.stage.template .env.stage
#
# Then deploy with:
# docker compose -f docker-compose.stage.yml --env-file .env.stage up -d --build
# ===================================================================
# === PostgreSQL ===
POSTGRES_USER=nexus_user_stage
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
POSTGRES_DB=nexus_stage_db
POSTGRES_PORT=5438
# === Neo4j ===
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# === Qdrant (leave empty to disable API key auth in staging) ===
QDRANT_API_KEY=
# === Web App ===
WEB_PORT=5080
# === Google OAuth (Staging credentials) ===
GOOGLE_CLIENT_ID=placeholder_google_client_id_stage
GOOGLE_CLIENT_SECRET=placeholder_google_client_secret_stage
# === Gemini AI ===
GOOGLE_AI_API_KEY=placeholder_gemini_api_key_stage
# === Secure Admin Seed Password (MANDATORY in Staging environment) ===
# This password is used by DbInitializer during startup. Cannot be empty or 'Admin123!'.
NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD
# === Non-standard ports for auxiliary services ===
QDRANT_HTTP_PORT=6383
QDRANT_GRPC_PORT=6384
NEO4J_HTTP_PORT=7488
NEO4J_BOLT_PORT=7688
+41
View File
@@ -0,0 +1,41 @@
# ===================================================================
# NexusReader — Test Environment Variables
# ===================================================================
# Copy this file to `.env` and fill in the values before deployment:
# cp .env.test.template .env
#
# Then deploy with:
# docker compose -f docker-compose.test.yml up -d --build
# ===================================================================
# === PostgreSQL ===
POSTGRES_USER=nexus_user
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
POSTGRES_DB=nexus_test_db
POSTGRES_PORT=5433
# === Neo4j ===
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# === Qdrant (leave empty to disable API key auth) ===
QDRANT_API_KEY=
# === Web App ===
WEB_PORT=5050
# === Google OAuth (placeholder for test) ===
GOOGLE_CLIENT_ID=placeholder
GOOGLE_CLIENT_SECRET=placeholder
# === Gemini AI (placeholder for test) ===
GOOGLE_AI_API_KEY=placeholder
# === Admin Seed Password ===
NEXUS_ADMIN_PASSWORD=CHANGE_ME
# === Non-standard ports for auxiliary services ===
QDRANT_HTTP_PORT=6343
QDRANT_GRPC_PORT=6344
NEO4J_HTTP_PORT=7484
NEO4J_BOLT_PORT=7697
+2
View File
@@ -29,6 +29,8 @@ Thumbs.db
*.epub *.epub
.fake .fake
.env
.env.stage
src/NexusReader.Web/nexus.db src/NexusReader.Web/nexus.db
src/NexusReader.Web/wwwroot/covers/ src/NexusReader.Web/wwwroot/covers/
src/NexusReader.Web/wwwroot/uploads/ src/NexusReader.Web/wwwroot/uploads/
+2
View File
@@ -4,6 +4,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="FluentResults" Version="4.0.0" /> <PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Markdig" Version="0.38.0" />
<PackageVersion Include="Mapster" Version="10.0.7" /> <PackageVersion Include="Mapster" Version="10.0.7" />
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" /> <PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="MediatR" Version="12.1.1" /> <PackageVersion Include="MediatR" Version="12.1.1" />
+6
View File
@@ -2,12 +2,17 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy props files and solution-level configurations for Central Package Management
COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
# Copy csproj files and restore dependencies # Copy csproj files and restore dependencies
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"] COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"] COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"] COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"] COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"] COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
COPY ["src/NexusReader.Data/NexusReader.Data.csproj", "src/NexusReader.Data/"]
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"] COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj" RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
@@ -21,6 +26,7 @@ RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseApp
# Stage 2: Runtime # Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
+6 -1
View File
@@ -46,4 +46,9 @@ version: 1.0
> [!IMPORTANT] > [!IMPORTANT]
> **Git Workflow & Integration** > **Git Workflow & Integration**
> All tasks originating from the repository must be performed on a separate branch. To connect to the Git repository, use the `gitea` MCP server. > All tasks originating from the repository must be performed on a separate branch. Every new chat must be launched from the `develop` branch. To connect to the Git repository, use the `gitea` MCP server.
> [!IMPORTANT]
> **Docker Lifecycle Management**
> Before starting work, only the web (nexus) container needs to be stopped to prevent port/application conflicts (e.g., `./run-stage.sh --stop --nexus-only` or `-s -n`); database containers (PostgreSQL, Neo4j, Qdrant) should continue to run to support local development/debugging. After finishing work, a new version of the web container from the current branch should be rebuilt and restarted via `./run-stage.sh --nexus-only` (or `-n`).
+10
View File
@@ -36,3 +36,13 @@ Run test suite:
```bash ```bash
dotnet test --no-restore dotnet test --no-restore
``` ```
## 🗄️ Database Migrations
Automatic database migrations at startup (`MigrateAsync()`) have been disabled to ensure compatibility with Native AOT compilation and prevent locking issues in multi-instance environments.
To apply database migrations locally, run the EF Core migration command from the solution root:
```bash
dotnet ef database update --project src/NexusReader.Infrastructure --startup-project src/NexusReader.Web
```
+103
View File
@@ -0,0 +1,103 @@
services:
db:
image: pgvector/pgvector:pg17
container_name: nexus-db-stage
environment:
POSTGRES_USER: ${POSTGRES_USER:-nexus_user_stage}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-nexus_stage_db}
ports:
- "${POSTGRES_PORT:-5438}:5432"
volumes:
- pgdata_stage:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-nexus_user_stage} -d $${POSTGRES_DB:-nexus_stage_db}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-stage
restart: unless-stopped
web:
build:
context: .
dockerfile: Dockerfile
container_name: nexus-web-stage
ports:
- "${WEB_PORT:-5080}:5000"
environment:
- ASPNETCORE_ENVIRONMENT=Staging
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_stage_db};Username=${POSTGRES_USER:-nexus_user_stage};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- ConnectionStrings__QdrantConnection=http://qdrant:6334
- Qdrant__ApiKey=${QDRANT_API_KEY:-}
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder_google_client_id_stage}
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder_google_client_secret_stage}
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder_gemini_api_key_stage}
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required}
depends_on:
db:
condition: service_healthy
qdrant:
condition: service_healthy
neo4j:
condition: service_healthy
volumes:
- stage_www_uploads:/app/wwwroot/uploads
- stage_www_covers:/app/wwwroot/covers
networks:
- nexus-stage
restart: unless-stopped
qdrant:
image: qdrant/qdrant:latest
container_name: nexus-qdrant-stage
environment:
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
ports:
- "${QDRANT_HTTP_PORT:-6383}:6333"
- "${QDRANT_GRPC_PORT:-6384}:6334"
volumes:
- qdrant_stage_data:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-stage
restart: unless-stopped
neo4j:
image: neo4j:5-community
container_name: nexus-neo4j-stage
environment:
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
ports:
- "${NEO4J_HTTP_PORT:-7488}:7474"
- "${NEO4J_BOLT_PORT:-7688}:7687"
volumes:
- neo4j_stage_data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s
networks:
- nexus-stage
restart: unless-stopped
volumes:
pgdata_stage:
qdrant_stage_data:
neo4j_stage_data:
stage_www_uploads:
stage_www_covers:
networks:
nexus-stage:
driver: bridge
+97
View File
@@ -0,0 +1,97 @@
services:
db:
image: pgvector/pgvector:pg17
container_name: nexus-db-test
environment:
POSTGRES_USER: ${POSTGRES_USER:-nexus_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-nexus_test_db}
ports:
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- pgdata_test:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexus_user} -d ${POSTGRES_DB:-nexus_test_db}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-test
restart: unless-stopped
web:
build:
context: .
dockerfile: Dockerfile
container_name: nexus-web-test
ports:
- "${WEB_PORT:-5050}:5000"
environment:
- ASPNETCORE_ENVIRONMENT=Test
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_test_db};Username=${POSTGRES_USER:-nexus_user};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- ConnectionStrings__QdrantConnection=http://qdrant:6334
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required}
depends_on:
db:
condition: service_healthy
qdrant:
condition: service_healthy
neo4j:
condition: service_healthy
networks:
- nexus-test
restart: unless-stopped
qdrant:
image: qdrant/qdrant:latest
container_name: nexus-qdrant-test
environment:
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
ports:
- "${QDRANT_HTTP_PORT:-6343}:6333"
- "${QDRANT_GRPC_PORT:-6344}:6334"
volumes:
- qdrant_test_data:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-test
restart: unless-stopped
neo4j:
image: neo4j:5-community
container_name: nexus-neo4j-test
environment:
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
ports:
- "${NEO4J_HTTP_PORT:-7484}:7474"
- "${NEO4J_BOLT_PORT:-7697}:7687"
volumes:
- neo4j_test_data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s
networks:
- nexus-test
restart: unless-stopped
volumes:
pgdata_test:
qdrant_test_data:
neo4j_test_data:
networks:
nexus-test:
driver: bridge
Executable
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# -------------------------------------------------------------
# Staging Deploy & Orchestration Helper for NexusReader
# -------------------------------------------------------------
set -e
NEXUS_ONLY=false
STOP=false
for arg in "$@"; do
case $arg in
--nexus-only|-n)
NEXUS_ONLY=true
;;
--stop|-s)
STOP=true
;;
esac
done
ENV_FILE=".env.stage"
TEMPLATE_FILE=".env.stage.template"
COMPOSE_FILE="docker-compose.stage.yml"
if [ "$STOP" = true ]; then
echo "🛑 Stopping staging environment..."
if [ ! -f "$ENV_FILE" ] && [ -f "$TEMPLATE_FILE" ]; then
cp "$TEMPLATE_FILE" "$ENV_FILE"
fi
if [ "$NEXUS_ONLY" = true ]; then
echo "🧹 Stopping and removing only the web (nexus) container..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
else
echo "🧹 Stopping all containers..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
docker compose down --remove-orphans 2>/dev/null || true
fi
echo "✅ Staging environment stopped."
exit 0
fi
echo "🏁 Starting staging environment orchestration..."
if [ "$NEXUS_ONLY" = true ]; then
echo "️ Mode: --nexus-only (only the web/nexus application container will be modified)"
fi
# 1. Create .env.stage if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$TEMPLATE_FILE" ]; then
echo "📄 Creating $ENV_FILE from $TEMPLATE_FILE..."
cp "$TEMPLATE_FILE" "$ENV_FILE"
else
echo "❌ Error: Template file $TEMPLATE_FILE not found."
exit 1
fi
fi
# 2. Check and generate secure random passwords for placeholders
if grep -q "CHANGE_ME_TO_STRONG_PASSWORD" "$ENV_FILE"; then
echo "🔐 Generating secure random passwords in $ENV_FILE..."
PG_PASS=$(openssl rand -hex 16)
NEO_PASS=$(openssl rand -hex 16)
# Use standard sed compatible with Linux
sed -i "s/POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/POSTGRES_PASSWORD=$PG_PASS/g" "$ENV_FILE"
sed -i "s/NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD/NEO4J_PASSWORD=$NEO_PASS/g" "$ENV_FILE"
fi
if grep -q "CHANGE_ME_TO_SECURE_ADMIN_PASSWORD" "$ENV_FILE"; then
echo "🔐 Generating secure admin seed password in $ENV_FILE..."
ADMIN_PASS=$(openssl rand -hex 16)
sed -i "s/NEXUS_ADMIN_PASSWORD=CHANGE_ME_TO_SECURE_ADMIN_PASSWORD/NEXUS_ADMIN_PASSWORD=$ADMIN_PASS/g" "$ENV_FILE"
fi
if grep -q "^QDRANT_API_KEY=$" "$ENV_FILE" || grep -q "^QDRANT_API_KEY=[[:space:]]*$" "$ENV_FILE"; then
echo "🔐 Generating secure random Qdrant API key in $ENV_FILE..."
QD_KEY=$(openssl rand -hex 16)
sed -i "s/^QDRANT_API_KEY=.*/QDRANT_API_KEY=$QD_KEY/g" "$ENV_FILE"
fi
# Load staging variables for local execution context (needed for ports/migrations)
# Clean up carriage returns just in case
POSTGRES_USER=$(grep "^POSTGRES_USER=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
POSTGRES_PASSWORD=$(grep "^POSTGRES_PASSWORD=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
POSTGRES_DB=$(grep "^POSTGRES_DB=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
POSTGRES_PORT=$(grep "^POSTGRES_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
WEB_PORT=$(grep "^WEB_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
QDRANT_HTTP_PORT=$(grep "^QDRANT_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
NEO4J_HTTP_PORT=$(grep "^NEO4J_HTTP_PORT=" "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
# Fallbacks in case env parsing is empty
POSTGRES_PORT=${POSTGRES_PORT:-5438}
WEB_PORT=${WEB_PORT:-5080}
# 3. Stop any conflicting Docker Compose environments
if [ "$NEXUS_ONLY" = true ]; then
echo "🧹 Stopping and removing only the web (nexus) container..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop web || true
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" rm -f web || true
else
echo "🧹 Stopping existing containers..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans || true
docker compose down --remove-orphans 2>/dev/null || true
fi
# 4. Build and start containers
if [ "$NEXUS_ONLY" = true ]; then
echo "🚀 Building and restarting only the web (nexus) container..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build web
else
echo "🚀 Building and starting staging containers..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build
fi
# 5. Wait for Database to be healthy
echo "⏳ Waiting for database (nexus-db-stage) to become healthy..."
MAX_ATTEMPTS=30
attempt=0
until [ "$(docker inspect --format='{{json .State.Health.Status}}' nexus-db-stage 2>/dev/null)" == "\"healthy\"" ]; do
sleep 2
attempt=$((attempt + 1))
if [ $attempt -ge $MAX_ATTEMPTS ]; then
echo "❌ Timeout: Database container never became healthy."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs db
exit 1
fi
done
echo "✅ Database is healthy!"
# 6. Apply Entity Framework migrations
echo "🔄 Applying EF Core migrations to staging database on port $POSTGRES_PORT..."
export ConnectionStrings__PostgresConnection="Host=127.0.0.1;Port=$POSTGRES_PORT;Database=$POSTGRES_DB;Username=$POSTGRES_USER;Password=$POSTGRES_PASSWORD"
dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web --no-build
# 7. Wait for Web Application to respond
echo "⏳ Waiting for Web Application to start on http://localhost:$WEB_PORT/health..."
MAX_WEB_ATTEMPTS=30
web_attempt=0
until curl -s -f "http://localhost:$WEB_PORT/health" >/dev/null; do
sleep 2
web_attempt=$((web_attempt + 1))
if [ $web_attempt -ge $MAX_WEB_ATTEMPTS ]; then
echo "⚠️ Warning: Web app is not responding yet on http://localhost:$WEB_PORT/health, but let's check logs..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" logs web
break
fi
done
echo "🎉 Staging environment is ready!"
echo "--------------------------------------------------------"
echo "🌐 Web Application: http://localhost:$WEB_PORT"
echo "🗄️ PostgreSQL Port: $POSTGRES_PORT"
echo "🔎 Neo4j Console: http://localhost:$NEO4J_HTTP_PORT"
echo "📊 Qdrant Service: http://localhost:$QDRANT_HTTP_PORT"
echo "--------------------------------------------------------"
@@ -0,0 +1,23 @@
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Read-only abstraction for fetching concepts map data.
/// Defined in the Application layer to avoid a direct dependency on EF Core / NexusReader.Data.
/// </summary>
public interface IConceptsMapReadRepository
{
/// <summary>
/// Gets the last read page ID for the specified user.
/// </summary>
Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all knowledge units associated with a book, scoped by tenant.
/// </summary>
Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(
Guid bookId,
string tenantId,
CancellationToken cancellationToken = default);
}
@@ -23,6 +23,11 @@ public interface IEbookRepository
/// </summary> /// </summary>
void AddEbook(Ebook ebook); void AddEbook(Ebook ebook);
/// <summary>
/// Finds an ebook by its unique identifier.
/// </summary>
Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Persists all staged changes to the underlying store. /// Persists all staged changes to the underlying store.
/// </summary> /// </summary>
@@ -0,0 +1,25 @@
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Abstraction for QuizResult and related User entity lookup.
/// Defined in the Application layer to maintain Clean Architecture isolation.
/// </summary>
public interface IQuizResultRepository
{
/// <summary>
/// Finds a user by ID to extract tenant context.
/// </summary>
Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new quiz result to the database.
/// </summary>
void AddQuizResult(QuizResult quizResult);
/// <summary>
/// Persists all staged changes to the repository.
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Provides access to user library ownership details, decoupling the relational database
/// structures from vector search and intelligence query operations.
/// </summary>
public interface IUserLibraryStore
{
/// <summary>
/// Retrieves a list of book IDs that are owned by or uploaded for the specified user.
/// </summary>
Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a dictionary mapping book IDs to their titles.
/// </summary>
Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Decoupled database store to retrieve active user reading states and chapter content.
/// </summary>
public interface IUserReadingStateStore
{
/// <summary>
/// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID.
/// </summary>
Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the text content of a specific chapter/page by its ID.
/// </summary>
Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace NexusReader.Application.Abstractions.Persistence;
/// <summary>
/// Represents a chunk of text retrieved from the semantic vector database.
/// </summary>
public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = "", string BookTitle = "", string ChapterTitle = "");
/// <summary>
/// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer.
/// </summary>
public interface IVectorSearchStore
{
/// <summary>
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches.
/// </summary>
Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
/// <summary>
/// Searches within a whitelist of owned book IDs for the best semantic matches.
/// </summary>
Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default);
/// <summary>
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID.
/// </summary>
Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
using FluentResults;
using NexusReader.Application.Queries.Concepts;
namespace NexusReader.Application.Abstractions.Services;
public interface IConceptsMapService
{
Task<Result<BookConceptsMapResultDto>> GetConceptsMapAsync(Guid bookId);
}
@@ -0,0 +1,17 @@
using FluentResults;
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service abstraction to extract raw text content from EPUB chapters.
/// </summary>
public interface IEpubExtractor
{
/// <summary>
/// Extracts the sanitized, plain-text content of each chapter in the EPUB file.
/// </summary>
/// <param name="relativePath">The relative storage path of the EPUB file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of plain-text chapters, or a failure result.</returns>
Task<Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default);
}
@@ -20,4 +20,17 @@ public interface IEpubReader
int chapterIndex, int chapterIndex,
string? userId = null, string? userId = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a resource (like an image) from the EPUB as a byte array.
/// </summary>
/// <param name="ebookId">The unique ID of the ebook to read.</param>
/// <param name="resourcePath">The path of the resource within the EPUB archive.</param>
/// <param name="userId">The authenticated user's ID (used for tenant isolation).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Result<byte[]>> GetEpubResourceAsync(
Guid ebookId,
string resourcePath,
string? userId = null,
CancellationToken cancellationToken = default);
} }
@@ -11,4 +11,5 @@ public interface IIdentityService
Task<Result> LogoutAsync(); Task<Result> LogoutAsync();
Task<Result<UserProfileDto>> GetProfileAsync(); Task<Result<UserProfileDto>> GetProfileAsync();
Task<Result> RefreshTokenAsync(); Task<Result> RefreshTokenAsync();
void ClearCache();
} }
@@ -1,5 +1,6 @@
using FluentResults; using FluentResults;
using NexusReader.Application.DTOs.AI; using NexusReader.Application.DTOs.AI;
using NexusReader.Application.Queries.Intelligence;
namespace NexusReader.Application.Abstractions.Services; namespace NexusReader.Application.Abstractions.Services;
@@ -13,6 +14,7 @@ public interface IKnowledgeService
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default); Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default); Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default); Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default);
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default); Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
} }
@@ -0,0 +1,13 @@
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection.
/// Intended to have a Singleton lifetime.
/// </summary>
public interface ISanitizerService
{
/// <summary>
/// Sanitizes the input string and returns a clean, safe version.
/// </summary>
string Sanitize(string input);
}
@@ -0,0 +1,18 @@
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// General file storage service interface for handling media uploads.
/// Intended to have a Scoped lifetime.
/// </summary>
public interface IStorageService
{
/// <summary>
/// Uploads a file stream and returns its public URL/path.
/// </summary>
Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType);
/// <summary>
/// Uploads file bytes and returns its public URL/path.
/// </summary>
Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType);
}
@@ -0,0 +1,10 @@
using FluentResults;
using NexusReader.Domain.Enums;
namespace NexusReader.Application.Abstractions.Services;
public interface IUserPreferenceStore
{
Task<Result> SaveThemePreferenceAsync(ThemeMode mode);
Task<Result<ThemeMode>> GetThemePreferenceAsync();
}
@@ -1,5 +1,8 @@
using FluentResults; using FluentResults;
using System.Linq;
using MediatR; using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
@@ -11,13 +14,16 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
{ {
private readonly IEbookRepository _ebookRepository; private readonly IEbookRepository _ebookRepository;
private readonly IBookStorageService _storageService; private readonly IBookStorageService _storageService;
private readonly IServiceScopeFactory _scopeFactory;
public IngestEbookCommandHandler( public IngestEbookCommandHandler(
IEbookRepository ebookRepository, IEbookRepository ebookRepository,
IBookStorageService storageService) IBookStorageService storageService,
IServiceScopeFactory scopeFactory)
{ {
_ebookRepository = ebookRepository; _ebookRepository = ebookRepository;
_storageService = storageService; _storageService = storageService;
_scopeFactory = scopeFactory;
} }
public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken) public async Task<Result<Guid>> Handle(IngestEbookCommand request, CancellationToken cancellationToken)
@@ -72,6 +78,43 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
_ebookRepository.AddEbook(ebook); _ebookRepository.AddEbook(ebook);
await _ebookRepository.SaveChangesAsync(cancellationToken); await _ebookRepository.SaveChangesAsync(cancellationToken);
// 4. Trigger asynchronous background processing and vector indexing
_ = Task.Run(async () =>
{
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<IngestEbookCommandHandler>>();
var broadcaster = scope.ServiceProvider.GetRequiredService<ISyncBroadcaster>();
try
{
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var result = await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId));
if (result.IsFailed)
{
var errorMsg = string.Join("; ", result.Errors.Select(e => e.Message));
logger.LogError("[IngestEbook] Background ebook processing failed for Ebook {EbookId}: {Error}", ebook.Id, errorMsg);
await broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Błąd indeksowania: {errorMsg}",
1.0);
}
}
catch (Exception ex)
{
logger.LogError(ex, "[IngestEbook] Exception during background ebook processing for Ebook {EbookId}", ebook.Id);
try
{
await broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Błąd krytyczny podczas przetwarzania e-booka: {ex.Message}",
1.0);
}
catch
{
// Ignore broadcast failures to prevent crashes
}
}
});
return Result.Ok(ebook.Id); return Result.Ok(ebook.Id);
} }
catch (Exception ex) catch (Exception ex)
@@ -0,0 +1,176 @@
using System.Text.RegularExpressions;
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Commands.Library;
public record ProcessEbookCommand(
Guid EbookId,
string UserId,
string TenantId
) : ICommand<bool>;
public class ProcessEbookCommandHandler : IRequestHandler<ProcessEbookCommand, Result<bool>>
{
private readonly IEbookRepository _ebookRepository;
private readonly IKnowledgeService _knowledgeService;
private readonly IEpubExtractor _epubExtractor;
private readonly ISyncBroadcaster _broadcaster;
private readonly ILogger<ProcessEbookCommandHandler> _logger;
public ProcessEbookCommandHandler(
IEbookRepository ebookRepository,
IKnowledgeService knowledgeService,
IEpubExtractor epubExtractor,
ISyncBroadcaster broadcaster,
ILogger<ProcessEbookCommandHandler> logger)
{
_ebookRepository = ebookRepository;
_knowledgeService = knowledgeService;
_epubExtractor = epubExtractor;
_broadcaster = broadcaster;
_logger = logger;
}
public async Task<Result<bool>> Handle(ProcessEbookCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("[ProcessEbook] Starting background processing for Ebook: {EbookId}", request.EbookId);
try
{
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken);
var ebook = await _ebookRepository.FindByIdAsync(request.EbookId, cancellationToken);
if (ebook == null)
{
_logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId);
return Result.Fail<bool>($"Ebook nie znaleziony w bazie danych: {request.EbookId}");
}
_logger.LogInformation("[ProcessEbook] Extracting chapters text for Ebook: {Title} ({FilePath})", ebook.Title, ebook.FilePath);
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Otwieranie i parsowanie pliku EPUB...", 0.1, cancellationToken);
var extractionResult = await _epubExtractor.ExtractChaptersTextAsync(ebook.FilePath, cancellationToken);
if (extractionResult.IsFailed)
{
var errorMsg = extractionResult.Errors.FirstOrDefault()?.Message ?? "Failed to extract text chapters.";
_logger.LogError("[ProcessEbook] Extraction failed: {Error}", errorMsg);
return Result.Fail<bool>(extractionResult.Errors);
}
var chapters = extractionResult.Value;
if (chapters == null || !chapters.Any())
{
_logger.LogWarning("[ProcessEbook] EPUB has no readable content files: {EbookId}", request.EbookId);
return Result.Fail<bool>("EPUB nie zawiera czytelnych rozdziałów.");
}
int totalChapters = chapters.Count;
_logger.LogInformation("[ProcessEbook] Processing {Count} chapters for Ebook: {Title}", totalChapters, ebook.Title);
await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, $"Analizowanie struktury ({totalChapters} rozdziałów)...", 0.15, cancellationToken);
int processedChapters = 0;
for (int i = 0; i < totalChapters; i++)
{
var cleanText = chapters[i];
if (cleanText.Length < 100)
{
_logger.LogInformation("[ProcessEbook] Skipping chapter {Index} (text too short: {Length} chars)", i, cleanText.Length);
processedChapters++;
continue;
}
// Chunk the text to maintain granular Knowledge Units
var chunks = ChunkText(cleanText, 3000);
_logger.LogInformation("[ProcessEbook] Chapter {Index} split into {ChunkCount} chunk(s)", i, chunks.Count);
foreach (var chunk in chunks)
{
try
{
// Invoke GetKnowledgeMapAsync to extract, embed, and upsert knowledge units
var result = await _knowledgeService.GetKnowledgeMapAsync(chunk, request.TenantId, request.EbookId, cancellationToken);
if (result.IsFailed)
{
_logger.LogWarning("[ProcessEbook] Failed to generate knowledge map for a chunk of chapter {Index}: {Error}", i, result.Errors.FirstOrDefault()?.Message);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[ProcessEbook] Exception during AI vectorization of chapter {Index} chunk", i);
}
}
processedChapters++;
double progress = 0.15 + (0.75 * processedChapters / totalChapters);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Przetwarzanie rozdziału {processedChapters} z {totalChapters} przez AI...",
progress,
cancellationToken);
}
// Mark the ebook as ready
ebook.IsReadyForReading = true;
await _ebookRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
"Indeksowanie wektorowe e-booka przez Nexus AI zakończone pomyślnie!",
1.0,
cancellationToken);
return Result.Ok(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "[ProcessEbook] Critical error during background EPUB vectorization of ebook {EbookId}", request.EbookId);
await _broadcaster.BroadcastIngestionProgressAsync(
request.UserId,
$"Błąd indeksowania: {ex.Message}",
1.0,
cancellationToken);
return Result.Fail<bool>(new Error("Wystąpił błąd podczas indeksowania e-booka przez AI").CausedBy(ex));
}
}
private static List<string> ChunkText(string text, int maxWords = 3000)
{
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var chunks = new List<string>();
if (words.Length <= maxWords)
{
chunks.Add(text);
return chunks;
}
var currentChunk = new List<string>();
int count = 0;
foreach (var word in words)
{
currentChunk.Add(word);
count++;
if (count >= maxWords)
{
chunks.Add(string.Join(" ", currentChunk));
currentChunk.Clear();
count = 0;
}
}
if (currentChunk.Any())
{
chunks.Add(string.Join(" ", currentChunk));
}
return chunks;
}
}
@@ -0,0 +1,10 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Commands.Quiz;
public record SubmitQuizResultCommand(
string UserId,
string Topic,
int Score,
int TotalQuestions) : ICommand;
@@ -0,0 +1,41 @@
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Commands.Quiz;
public sealed class SubmitQuizResultCommandHandler : ICommandHandler<SubmitQuizResultCommand>
{
private readonly IQuizResultRepository _quizResultRepository;
public SubmitQuizResultCommandHandler(IQuizResultRepository quizResultRepository)
{
_quizResultRepository = quizResultRepository;
}
public async Task<Result> Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken)
{
var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Fail("User not found.");
}
var quizResult = new QuizResult
{
Id = Guid.NewGuid(),
UserId = request.UserId,
TenantId = string.IsNullOrEmpty(user.TenantId) ? "global" : user.TenantId,
Topic = request.Topic,
Score = request.Score,
TotalQuestions = request.TotalQuestions,
CompletedDate = DateTime.UtcNow
};
_quizResultRepository.AddQuizResult(quizResult);
await _quizResultRepository.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
}
@@ -0,0 +1,6 @@
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Domain.Enums;
namespace NexusReader.Application.Commands.User;
public record UpdateThemeCommand(string UserId, ThemeMode Mode) : ICommand;
@@ -0,0 +1,41 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Commands.User;
public class UpdateThemeCommandHandler : ICommandHandler<UpdateThemeCommand>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public UpdateThemeCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result> Handle(UpdateThemeCommand request, CancellationToken cancellationToken)
{
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var user = await dbContext.Users
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
return Result.Fail("User not found.");
}
user.ThemePreference = request.Mode;
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to save theme preference in database.").CausedBy(ex));
}
}
}
@@ -1,5 +1,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Collections.Generic;
using NexusReader.Application.Queries.Graph; using NexusReader.Application.Queries.Graph;
using NexusReader.Application.Queries.Intelligence;
namespace NexusReader.Application.Common; namespace NexusReader.Application.Common;
@@ -9,6 +11,28 @@ namespace NexusReader.Application.Common;
[JsonSerializable(typeof(GraphDataDto))] [JsonSerializable(typeof(GraphDataDto))]
[JsonSerializable(typeof(List<GraphNodeDto>))] [JsonSerializable(typeof(List<GraphNodeDto>))]
[JsonSerializable(typeof(List<GraphLinkDto>))] [JsonSerializable(typeof(List<GraphLinkDto>))]
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
[JsonSerializable(typeof(IntelligenceResponse))]
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.LocalBackupEnvelope))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.AutosaveChapterRequest))]
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand))]
[JsonSerializable(typeof(NexusReader.Application.Features.Books.Commands.CreateBookCommand))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookRequestDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreateBookResponseDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorDashboardDataDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.DashboardMetricsDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookDto))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto))]
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookDto>))]
[JsonSerializable(typeof(List<NexusReader.Application.DTOs.Creator.CreatorBookRevisionDto>))]
public partial class AppJsonContext : JsonSerializerContext public partial class AppJsonContext : JsonSerializerContext
{ {
} }
@@ -0,0 +1,28 @@
namespace NexusReader.Application.Common;
/// <summary>
/// Configurations for the monetization engine, controlling the thresholds at which
/// search queries trigger paywalls.
/// </summary>
public class RagMonetizationOptions
{
public const string SectionName = "RagMonetization";
/// <summary>
/// The baseline score threshold above which global content might trigger a paywall if there is no local content.
/// Default: 0.45.
/// </summary>
public double BaselineThreshold { get; set; } = 0.45;
/// <summary>
/// The similarity gap (Delta) required between global and local content to trigger an upgrade paywall.
/// Default: 0.15.
/// </summary>
public double DeltaThreshold { get; set; } = 0.15;
/// <summary>
/// The absolute score required from global content to trigger an upgrade paywall.
/// Default: 0.70.
/// </summary>
public double UpgradeThreshold { get; set; } = 0.70;
}
@@ -13,4 +13,6 @@ public class CitationDto
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID
public string Snippet { get; set; } = string.Empty; // Verified text snippet from context public string Snippet { get; set; } = string.Empty; // Verified text snippet from context
public string SourceBook { get; set; } = string.Empty; // Book title or description public string SourceBook { get; set; } = string.Empty; // Book title or description
public string? Author { get; set; }
public int? PageNumber { get; set; }
} }
@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
namespace NexusReader.Application.DTOs.Creator;
/// <summary>
/// Telemetry metrics for the Creator Dashboard.
/// </summary>
public record DashboardMetricsDto(
int TotalReads,
double AvgReadTimeMinutes,
int ActiveReaders,
decimal GrossRevenue
);
/// <summary>
/// Lightweight revision details for the Creator Dashboard.
/// </summary>
public record CreatorBookRevisionDto(
Guid Id,
string VersionString,
bool IsPublished,
DateTime CreatedAt,
DateTime? PublishedAt
);
/// <summary>
/// Lightweight book publication details for the Creator Dashboard.
/// </summary>
public record CreatorBookDto(
Guid Id,
string Title,
int WordCount,
int AggregatedReads,
Guid? FirstChapterId,
CreatorBookRevisionDto? LivePublishedRevision,
CreatorBookRevisionDto? CurrentDraftRevision
);
/// <summary>
/// Root data envelope for Creator Dashboard loading.
/// </summary>
public record CreatorDashboardDataDto(
DashboardMetricsDto Metrics,
List<CreatorBookDto> Books
);
/// <summary>
/// Request DTO for creating a new Book.
/// </summary>
public record CreateBookRequestDto(
string Title,
string? Description
);
/// <summary>
/// Response DTO for creating a new Book.
/// </summary>
public record CreateBookResponseDto(
Guid BookId
);
@@ -0,0 +1,33 @@
namespace NexusReader.Application.DTOs.Media;
// Note: These DTOs are registered in AppJsonContext.cs for JSON source generation.
/// <summary>
/// Request DTO for chapter validation/sanitization.
/// </summary>
public record ValidateChapterRequest(string Content);
/// <summary>
/// Response DTO containing sanitized chapter content.
/// </summary>
public record ValidateChapterResponse(string SanitizedContent);
/// <summary>
/// Response DTO containing the uploaded media file URL.
/// </summary>
public record UploadResultDto(string Url);
/// <summary>
/// Represents a structured JSON backup envelope stored in LocalStorage.
/// </summary>
public class LocalBackupEnvelope
{
public Guid ChapterId { get; set; }
public DateTime Timestamp { get; set; }
public string MarkdownContent { get; set; } = string.Empty;
}
/// <summary>
/// Request DTO for chapter autosaving.
/// </summary>
public record AutosaveChapterRequest(string MarkdownContent);
@@ -0,0 +1,5 @@
using NexusReader.Domain.Enums;
namespace NexusReader.Application.DTOs.User;
public record UpdateThemeRequest(ThemeMode Mode);
@@ -1,12 +1,15 @@
using NexusReader.Application.Constants; using NexusReader.Application.Constants;
using NexusReader.Domain.Enums;
namespace NexusReader.Application.DTOs.User; namespace NexusReader.Application.DTOs.User;
public record UserProfileDto public record UserProfileDto
{ {
public string Email { get; init; } = string.Empty; public string Email { get; init; } = string.Empty;
public string UserId { get; init; } = string.Empty;
public int AITokensUsed { get; init; } public int AITokensUsed { get; init; }
public Guid TenantId { get; init; } public Guid TenantId { get; init; }
public ThemeMode ThemePreference { get; init; } = ThemeMode.System;
/// <summary> /// <summary>
/// Relational data for the current subscription plan. /// Relational data for the current subscription plan.
@@ -15,11 +18,12 @@ public record UserProfileDto
public int AverageQuizScore { get; init; } public int AverageQuizScore { get; init; }
/// <summary> public string? DisplayName { get; init; }
/// Summary of the last read book. public int BooksReadCount { get; init; }
/// </summary> public int ConceptsMappedCount { get; init; }
public LastReadBookDto? LastReadBook { get; init; } public LastReadBookDto? LastReadBook { get; init; }
public IReadOnlyList<QuizResultDto> RecentQuizzes { get; init; } = Array.Empty<QuizResultDto>();
public IReadOnlyList<MappedConceptDto> MappedConcepts { get; init; } = Array.Empty<MappedConceptDto>();
public string[] Roles { get; init; } = Array.Empty<string>(); public string[] Roles { get; init; } = Array.Empty<string>();
// Helper properties for UI compatibility // Helper properties for UI compatibility
@@ -28,6 +32,14 @@ public record UserProfileDto
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel; public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
} }
public record MappedConceptDto
{
public string Id { get; init; } = string.Empty;
public string Type { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public string DisplayLabel => Content.Length > 25 ? Content.Substring(0, 22) + "..." : Content;
}
public record LastReadBookDto public record LastReadBookDto
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
@@ -38,4 +50,15 @@ public record LastReadBookDto
public string? LastChapter { get; init; } public string? LastChapter { get; init; }
public int LastChapterIndex { get; init; } public int LastChapterIndex { get; init; }
public string? Description { get; init; } public string? Description { get; init; }
public bool IsReadyForReading { get; init; }
}
public record QuizResultDto
{
public Guid Id { get; init; }
public string Topic { get; init; } = string.Empty;
public int Score { get; init; }
public int TotalQuestions { get; init; }
public double Percentage { get; init; }
public DateTime CompletedDate { get; init; }
} }
@@ -0,0 +1,18 @@
using System;
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Features.Books.Commands;
/// <summary>
/// Command to create a new Book, initialize its first Working Draft revision, and seed it with a default Introduction chapter.
/// </summary>
/// <param name="Title">The title of the new book.</param>
/// <param name="Description">An optional description of the book.</param>
/// <param name="UserId">The ID of the creator user.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
public record CreateBookCommand(
string Title,
string? Description,
string UserId,
string TenantId
) : ICommand<Guid>;
@@ -0,0 +1,103 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Application.Features.Books.Commands;
/// <summary>
/// MediatR handler for creating a Book, creating its initial Working Draft revision,
/// and seeding a default first chapter ("Introduction") in an atomic database transaction.
/// </summary>
public class CreateBookCommandHandler : ICommandHandler<CreateBookCommand, Guid>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public CreateBookCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result<Guid>> Handle(CreateBookCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Title))
{
return Result.Fail<Guid>(new Error("Book title is required."));
}
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
// 1. Instantiate the Book record mapping Title, UserId, and TenantId
var book = new Book
{
Id = Guid.NewGuid(),
Title = request.Title.Trim(),
UserId = request.UserId,
TenantId = request.TenantId,
CurrentDraftRevisionId = null,
LivePublishedRevisionId = null
};
dbContext.Books.Add(book);
// 2. Instantiate the initial BookRevision designated as "Working Draft"
var draftRevision = new BookRevision
{
Id = Guid.NewGuid(),
BookId = book.Id,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
dbContext.BookRevisions.Add(draftRevision);
// 3. Automatically instantiate and append a default first Chapter to this new revision
var introChapter = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = draftRevision.Id,
Title = "Introduction",
MarkdownContent = "# Introduction\nStart writing here...",
SortOrder = 1
};
dbContext.Chapters.Add(introChapter);
// Save first to generate DB references
await dbContext.SaveChangesAsync(cancellationToken);
// 4. Inject the newly instantiated draft revision ID back into Book.CurrentDraftRevisionId
book.CurrentDraftRevisionId = draftRevision.Id;
// Save the updated Book link
await dbContext.SaveChangesAsync(cancellationToken);
// Commit transaction
await transaction.CommitAsync(cancellationToken);
return Result.Ok(book.Id);
}
catch (Exception ex)
{
try
{
await transaction.RollbackAsync(cancellationToken);
}
catch (Exception rollbackEx)
{
Console.WriteLine($"[CreateBook] Transaction rollback failed: {rollbackEx.Message}");
}
return Result.Fail<Guid>(new Error($"Failed to create book: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,12 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Features.Books.Commands;
/// <summary>
/// Command to publish a new frozen version of a Book, and create a new Working Draft.
/// </summary>
/// <param name="BookId">The unique identifier of the Book to publish.</param>
/// <param name="CustomVersionString">The custom version string to apply (e.g. "v1.0").</param>
/// <param name="UserId">The ID of the user requesting the action.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
public record PublishBookVersionCommand(Guid BookId, string CustomVersionString, string UserId, string TenantId) : ICommand;
@@ -0,0 +1,112 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Domain.Exceptions;
namespace NexusReader.Application.Features.Books.Commands;
/// <summary>
/// MediatR handler for publishing a Book version and setting up the next Working Draft.
/// </summary>
public class PublishBookVersionCommandHandler : ICommandHandler<PublishBookVersionCommand>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public PublishBookVersionCommandHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Result> Handle(PublishBookVersionCommand request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Fetch the Book including its CurrentDraftRevision and all associated Chapters,
// enforcing that the book belongs to the requested TenantId and UserId to prevent cross-tenant data leaks.
var book = await dbContext.Books
.Include(b => b.CurrentDraftRevision)
.ThenInclude(r => r!.Chapters)
.FirstOrDefaultAsync(
b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId,
cancellationToken);
if (book == null)
{
return Result.Fail(new Error($"Book with ID '{request.BookId}' was not found."));
}
var oldDraftRevision = book.CurrentDraftRevision;
if (oldDraftRevision == null)
{
return Result.Fail(new Error("The book does not have an active draft revision to publish."));
}
// Start ACID transaction
using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
// 1. Update the current draft revision: Set IsPublished = true, PublishedAt = now, VersionString = custom
oldDraftRevision.IsPublished = true;
oldDraftRevision.PublishedAt = DateTime.UtcNow;
oldDraftRevision.VersionString = request.CustomVersionString;
// 2. Point the Book.LivePublishedRevisionId to this newly frozen revision ID
book.LivePublishedRevisionId = oldDraftRevision.Id;
// 3. Execute Deep Snapshot: Instantiate a brand new BookRevision representing the next "Working Draft"
var newDraftRevision = new BookRevision
{
Id = Guid.NewGuid(),
BookId = book.Id,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
dbContext.BookRevisions.Add(newDraftRevision);
// Replicate/clone chapters into new Chapter objects associated with the new draft revision.
// Reset identities by explicitly instantiating completely new Chapter objects with Guid.NewGuid().
foreach (var oldChapter in oldDraftRevision.Chapters)
{
var newChapter = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = newDraftRevision.Id,
Title = oldChapter.Title,
MarkdownContent = oldChapter.MarkdownContent,
SortOrder = oldChapter.SortOrder
};
dbContext.Chapters.Add(newChapter);
}
// 4. Assign the new draft revision ID to Book.CurrentDraftRevisionId
book.CurrentDraftRevisionId = newDraftRevision.Id;
// Save changes and commit transaction
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Result.Ok();
}
catch (Exception ex)
{
try
{
await transaction.RollbackAsync(cancellationToken);
}
catch (Exception rollbackEx)
{
Console.WriteLine($"[PublishBookVersion] Transaction rollback failed: {rollbackEx.Message}");
}
return Result.Fail(new Error($"Failed to publish book version: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using NexusReader.Application.Queries.Graph;
namespace NexusReader.Application.Queries.Concepts;
public record BookConceptsMapResultDto(
[property: JsonPropertyName("nodes")] List<GraphNodeDto> Nodes,
[property: JsonPropertyName("lastReadBlockId")] string LastReadBlockId
);
@@ -0,0 +1,9 @@
using NexusReader.Application.Abstractions.Messaging;
namespace NexusReader.Application.Queries.Concepts;
public record GetBookConceptsMapQuery(
Guid BookId,
string UserId,
string TenantId
) : IQuery<BookConceptsMapResultDto>;
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Queries.Graph;
using NexusReader.Application.Utilities;
namespace NexusReader.Application.Queries.Concepts;
internal sealed class GetBookConceptsMapQueryHandler : IQueryHandler<GetBookConceptsMapQuery, BookConceptsMapResultDto>
{
private readonly IConceptsMapReadRepository _repository;
public GetBookConceptsMapQueryHandler(IConceptsMapReadRepository repository)
{
_repository = repository;
}
public async Task<Result<BookConceptsMapResultDto>> Handle(GetBookConceptsMapQuery request, CancellationToken cancellationToken)
{
// 1. Fetch user to extract reading progress (LastReadPageId)
var lastReadPageId = await _repository.GetLastReadPageIdAsync(request.UserId, cancellationToken);
var lastReadBlockId = lastReadPageId ?? string.Empty;
// 2. Fetch all KnowledgeUnits associated with the ebook and user's tenant
var units = await _repository.GetKnowledgeUnitsForBookAsync(request.BookId, request.TenantId, cancellationToken);
var nodes = new List<GraphNodeDto>();
foreach (var unit in units)
{
// Only process units representing sections or conceptual milestones (usually starting with "seg-")
if (string.IsNullOrEmpty(unit.Id) || !unit.Id.StartsWith("seg-"))
{
continue;
}
string label = unit.Id;
string group = "concept";
string summary = unit.Content;
var keyTerms = new List<string>();
if (!string.IsNullOrEmpty(unit.MetadataJson))
{
try
{
using var doc = JsonDocument.Parse(unit.MetadataJson);
if (doc.RootElement.TryGetProperty("label", out var labelProp))
label = labelProp.GetString() ?? label;
if (doc.RootElement.TryGetProperty("group", out var groupProp))
group = groupProp.GetString() ?? group;
if (doc.RootElement.TryGetProperty("summary", out var summaryProp))
summary = summaryProp.GetString() ?? summary;
if (doc.RootElement.TryGetProperty("key_terms", out var ktProp) && ktProp.ValueKind == JsonValueKind.Array)
{
foreach (var term in ktProp.EnumerateArray())
{
if (term.GetString() is string s)
keyTerms.Add(s);
}
}
}
catch
{
// Fallback to defaults
}
}
nodes.Add(new GraphNodeDto(
Id: unit.Id,
Label: label,
Group: group,
Description: unit.Content,
Type: unit.Type.ToString(),
Summary: summary,
KeyTerms: keyTerms
));
}
// Return sorted by the numeric value in the seg-ID to ensure topdown vertical alignment
var sortedNodes = nodes
.OrderBy(n => SegmentIdParser.Parse(n.Id))
.ToList();
return Result.Ok(new BookConceptsMapResultDto(sortedNodes, lastReadBlockId));
}
}
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.DTOs.Creator;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Exceptions;
namespace NexusReader.Application.Queries.Creator;
/// <summary>
/// Query to load all revisions for a specific Book, checking multi-tenant ownership boundaries.
/// </summary>
/// <param name="BookId">The unique identifier of the target Book.</param>
/// <param name="UserId">The ID of the creator requesting revision data.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
public record GetBookRevisionsQuery(Guid BookId, string UserId, string TenantId) : IQuery<List<CreatorBookRevisionDto>>;
/// <summary>
/// Handler that lists past revisions of a Book, verifying ownership to prevent cross-tenant leakages.
/// </summary>
public class GetBookRevisionsQueryHandler : IQueryHandler<GetBookRevisionsQuery, List<CreatorBookRevisionDto>>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public GetBookRevisionsQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<FluentResults.Result<List<CreatorBookRevisionDto>>> Handle(GetBookRevisionsQuery request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Verify the book exists and belongs to this tenant/user to prevent cross-tenant data leaks
var bookExists = await dbContext.Books
.AnyAsync(b => b.Id == request.BookId && b.UserId == request.UserId && b.TenantId == request.TenantId, cancellationToken);
if (!bookExists)
{
return FluentResults.Result.Fail<List<CreatorBookRevisionDto>>(new FluentResults.Error($"Book with ID '{request.BookId}' was not found."));
}
// Fetch all revisions sorted chronologically
var revisions = await dbContext.BookRevisions
.AsNoTracking()
.Where(r => r.BookId == request.BookId)
.OrderByDescending(r => r.CreatedAt)
.Select(r => new CreatorBookRevisionDto(
r.Id,
r.VersionString,
r.IsPublished,
r.CreatedAt,
r.PublishedAt
))
.ToListAsync(cancellationToken);
return FluentResults.Result.Ok(revisions);
}
}
@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Messaging;
using NexusReader.Application.DTOs.Creator;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Queries.Creator;
/// <summary>
/// Query to load aggregated Creator Dashboard telemetry metrics and book listings.
/// </summary>
/// <param name="UserId">The ID of the creator requesting dashboard data.</param>
/// <param name="TenantId">The tenant ID for multi-tenant isolation.</param>
public record GetCreatorDashboardDataQuery(string UserId, string TenantId) : IQuery<CreatorDashboardDataDto>;
/// <summary>
/// Handler that executes projection-only LINQ queries to aggregate metrics and compute word counts
/// without loading raw chapter content into memory or tracking them in the EF Core Change Tracker.
/// </summary>
public class GetCreatorDashboardDataQueryHandler : IQueryHandler<GetCreatorDashboardDataQuery, CreatorDashboardDataDto>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public GetCreatorDashboardDataQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<FluentResults.Result<CreatorDashboardDataDto>> Handle(GetCreatorDashboardDataQuery request, CancellationToken cancellationToken)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Execute projection-only LINQ query. The heavy MarkdownContent is projected only as integer lengths.
var projectedBooks = await dbContext.Books
.AsNoTracking()
.Where(b => b.UserId == request.UserId && b.TenantId == request.TenantId)
.Select(b => new
{
b.Id,
b.Title,
LivePublishedRevision = b.LivePublishedRevision == null ? null : new CreatorBookRevisionDto(
b.LivePublishedRevision.Id,
b.LivePublishedRevision.VersionString,
b.LivePublishedRevision.IsPublished,
b.LivePublishedRevision.CreatedAt,
b.LivePublishedRevision.PublishedAt
),
CurrentDraftRevision = b.CurrentDraftRevision == null ? null : new CreatorBookRevisionDto(
b.CurrentDraftRevision.Id,
b.CurrentDraftRevision.VersionString,
b.CurrentDraftRevision.IsPublished,
b.CurrentDraftRevision.CreatedAt,
b.CurrentDraftRevision.PublishedAt
),
FirstChapterId = b.CurrentDraftRevision == null
? (Guid?)null
: b.CurrentDraftRevision.Chapters.OrderBy(c => c.SortOrder).Select(c => c.Id).FirstOrDefault(),
ChapterContentLengths = b.CurrentDraftRevision == null
? new List<int>()
: b.CurrentDraftRevision.Chapters.Select(c => c.MarkdownContent.Length).ToList()
})
.ToListAsync(cancellationToken);
var booksList = new List<CreatorBookDto>();
int totalReads = 0;
int totalWords = 0;
foreach (var pBook in projectedBooks)
{
// Estimate word count (approx. 6 characters per word as a database-friendly standard length)
int wordCount = pBook.ChapterContentLengths.Sum(len => len / 6);
totalWords += wordCount;
// Generate deterministic simulated telemetry metrics scoped to this Book
int bookReads = Math.Abs(pBook.Id.GetHashCode() % 1000) + 120;
totalReads += bookReads;
var bookDto = new CreatorBookDto(
pBook.Id,
pBook.Title,
wordCount,
bookReads,
pBook.FirstChapterId,
pBook.LivePublishedRevision,
pBook.CurrentDraftRevision
);
booksList.Add(bookDto);
}
// Calculate aggregate dashboard metrics based on projected stats
int activeReaders = projectedBooks.Count == 0 ? 0 : Math.Abs(request.UserId.GetHashCode() % 15) + 3;
decimal grossRevenue = totalReads * 1.49m;
double avgReadTime = projectedBooks.Count == 0 ? 0 : Math.Round(totalWords / 250.0, 1); // standard 250 words per minute reading speed
var metrics = new DashboardMetricsDto(
totalReads,
avgReadTime,
activeReaders,
grossRevenue
);
return FluentResults.Result.Ok(new CreatorDashboardDataDto(metrics, booksList));
}
}
@@ -1,9 +1,27 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace NexusReader.Application.Queries.Graph; namespace NexusReader.Application.Queries.Graph;
public record GraphNodeDto(string Id, string Label, string Group, string? Type = null); public record GraphNodeDto(
public record GraphLinkDto(string Source, string Target, string RelationType, int Value = 1); [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("label")] string Label,
[property: JsonPropertyName("group")] string Group,
[property: JsonPropertyName("description")] string? Description = null,
[property: JsonPropertyName("type")] string? Type = null,
[property: JsonPropertyName("summary")] string? Summary = null,
[property: JsonPropertyName("key_terms")] List<string>? KeyTerms = null
);
public record GraphLinkDto(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("target")] string Target,
[property: JsonPropertyName("type")] string RelationType,
[property: JsonPropertyName("value")] int Value = 1
);
public record GraphDataDto public record GraphDataDto
{ {
public List<GraphNodeDto> Nodes { get; init; } = new(); [JsonPropertyName("nodes")] public List<GraphNodeDto> Nodes { get; init; } = new();
public List<GraphLinkDto> Links { get; init; } = new(); [JsonPropertyName("links")] public List<GraphLinkDto> Links { get; init; } = new();
} }
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Common;
using NexusReader.Application.DTOs.AI;
namespace NexusReader.Application.Queries.Intelligence;
/// <summary>
/// MediatR query to request global intelligence hybrid Q&A context.
/// </summary>
public record GetGlobalIntelligenceQuery(string QueryText, string UserId, string TenantId = "global")
: IRequest<Result<IntelligenceResponse>>;
/// <summary>
/// Request schema for global hybrid search queries.
/// </summary>
public record GetGlobalIntelligenceRequest(string QueryText);
/// <summary>
/// Response schema returning generated AI text, paywall status, and locked publishing details.
/// </summary>
public record IntelligenceResponse(
string ResponseText,
bool HasPaywall,
Guid? LockedBookId,
string? LockedBookTitle,
List<CitationDto>? Citations = null);
/// <summary>
/// Handles <see cref="GetGlobalIntelligenceQuery"/> by performing local/global dual searches,
/// executing monetization rules, and invoking Chat AI with appropriate gating logic.
/// </summary>
public class GetGlobalIntelligenceQueryHandler : IRequestHandler<GetGlobalIntelligenceQuery, Result<IntelligenceResponse>>
{
private readonly IUserLibraryStore _userLibraryStore;
private readonly IVectorSearchStore _vectorSearchStore;
private readonly IChatClient _chatClient;
private readonly RagMonetizationOptions _options;
public GetGlobalIntelligenceQueryHandler(
IUserLibraryStore userLibraryStore,
IVectorSearchStore vectorSearchStore,
IChatClient chatClient,
IOptions<RagMonetizationOptions> options)
{
_userLibraryStore = userLibraryStore;
_vectorSearchStore = vectorSearchStore;
_chatClient = chatClient;
_options = options.Value;
}
public async Task<Result<IntelligenceResponse>> Handle(GetGlobalIntelligenceQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.QueryText))
{
return Result.Fail("Question cannot be empty.");
}
try
{
// Step A: Fetch whitelisted BookIds
var whitelistedBookIds = await _userLibraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
// Step B & C: Vector Dual-Search with Resilient Trapping
List<VectorChunk> globalChunks = new();
List<VectorChunk> localChunks = new();
double globalScore = 0.0;
double localScore = 0.0;
try
{
// Execute searches
globalChunks = await _vectorSearchStore.SearchGlobalAsync(request.QueryText, request.TenantId, limit: 3, cancellationToken);
globalScore = globalChunks.Any() ? Math.Max(0.0, globalChunks.Max(c => c.Score)) : 0.0;
if (whitelistedBookIds.Any())
{
localChunks = await _vectorSearchStore.SearchLocalAsync(request.QueryText, request.TenantId, whitelistedBookIds, limit: 3, cancellationToken);
localScore = localChunks.Any() ? Math.Max(0.0, localChunks.Max(c => c.Score)) : 0.0;
}
}
catch (Exception ex)
{
// Resilient Error Trapping: transform connectivity anomalies into domain-friendly errors
return Result.Fail(new Error("Serwer wyszukiwania semantycznego jest tymczasowo niedostępny. Spróbuj ponownie później.").CausedBy(ex));
}
// Step D: Evaluate Monetization Thresholds
bool triggerPaywall = false;
if (localScore == 0.0 && globalScore > _options.BaselineThreshold)
{
triggerPaywall = true;
}
else if ((globalScore - localScore) > _options.DeltaThreshold && globalScore > _options.UpgradeThreshold)
{
triggerPaywall = true;
}
var chosenChunks = triggerPaywall ? globalChunks : localChunks;
// Fetch book titles for citations/paywall metadata
var chunkEbookIds = chosenChunks
.Where(c => Guid.TryParse(c.EbookId, out _))
.Select(c => Guid.Parse(c.EbookId))
.Distinct()
.ToList();
var bookTitles = await _userLibraryStore.GetBookTitlesAsync(chunkEbookIds, cancellationToken);
// Step E: Identify locked book if paywall triggered
Guid? lockedBookId = null;
string? lockedBookTitle = null;
if (triggerPaywall && globalChunks.Any())
{
var topGlobalChunk = globalChunks.OrderByDescending(c => c.Score).First();
if (Guid.TryParse(topGlobalChunk.EbookId, out var parsedLockedId))
{
lockedBookId = parsedLockedId;
bookTitles.TryGetValue(parsedLockedId, out lockedBookTitle);
if (string.IsNullOrEmpty(lockedBookTitle))
{
lockedBookTitle = "Nieznana książka";
}
}
}
// Format context blocks for LLM
var relatedContexts = new List<string>();
foreach (var chunk in chosenChunks)
{
var sourceId = chunk.EbookId;
relatedContexts.Add($"[Source ID: {sourceId}] {chunk.Content}");
}
var contextBlocksText = string.Join("\n\n", relatedContexts);
// Build LLM prompts
var systemPrompt = "You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.\n" +
"Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.\n" +
"If the context does not contain the answer, say: 'I cannot answer this based on the provided book context.'";
if (triggerPaywall)
{
var localScorePercent = (int)Math.Round(localScore * 100);
var globalScorePercent = (int)Math.Round(globalScore * 100);
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
systemPrompt += $"\n\nCRITICAL: You are operating in TEASER mode. The user does not own the source document named '{resolvedTitle}'. You are strictly allowed to provide only a 1-sentence foundational definition or answer based on the context to prove the system knows the solution. DO NOT output code blocks, implementation details, or bullet points. You must immediately terminate your response with this exact token format: [PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}].";
}
var messages = new List<Microsoft.Extensions.AI.ChatMessage>
{
new(Microsoft.Extensions.AI.ChatRole.System, systemPrompt),
new(Microsoft.Extensions.AI.ChatRole.User, $"Context:\n{contextBlocksText}\n\nQuestion: {request.QueryText}")
};
var chatOptions = new ChatOptions
{
Temperature = 0.0f,
MaxOutputTokens = 1000
};
var chatResponse = await _chatClient.GetResponseAsync(messages, chatOptions, cancellationToken);
var responseText = chatResponse.Text?.Trim() ?? string.Empty;
// Ensure the paywall token is appended if LLM misses it in teaser mode
if (triggerPaywall)
{
var localScorePercent = (int)Math.Round(localScore * 100);
var globalScorePercent = (int)Math.Round(globalScore * 100);
var resolvedTitle = lockedBookTitle ?? "Nieznana książka";
var paywallToken = $"[PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}]";
if (!responseText.Contains("[PAYWALL_TRIGGER:"))
{
responseText = responseText.Trim() + " " + paywallToken;
}
}
// Build citations list
var citations = new List<CitationDto>();
foreach (var chunk in chosenChunks)
{
var sourceBookName = "Unknown";
if (Guid.TryParse(chunk.EbookId, out var parsedId) && bookTitles.TryGetValue(parsedId, out var title))
{
sourceBookName = title;
}
citations.Add(new CitationDto
{
CitationId = chunk.EbookId,
Snippet = chunk.Content,
SourceBook = sourceBookName,
Author = null,
PageNumber = null
});
}
return Result.Ok(new IntelligenceResponse(
ResponseText: responseText,
HasPaywall: triggerPaywall,
LockedBookId: lockedBookId,
LockedBookTitle: lockedBookTitle,
Citations: citations
));
}
catch (Exception ex)
{
return Result.Fail(new Error("Nieoczekiwany błąd serwera podczas przetwarzania zapytania.").CausedBy(ex));
}
}
}
@@ -37,7 +37,8 @@ public class GetMyEbooksQueryHandler : IRequestHandler<GetMyEbooksQuery, Result<
Progress = e.Progress, Progress = e.Progress,
LastChapter = e.LastChapter ?? "Rozpoczynanie...", LastChapter = e.LastChapter ?? "Rozpoczynanie...",
LastChapterIndex = e.LastChapterIndex, LastChapterIndex = e.LastChapterIndex,
Description = e.Description Description = e.Description,
IsReadyForReading = e.IsReadyForReading
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -1,18 +1,7 @@
using FluentResults; using FluentResults;
using MediatR; using MediatR;
using Pgvector;
using Pgvector.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI; using NexusReader.Application.DTOs.AI;
using Microsoft.Extensions.AI;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Resilience;
using Polly;
using Polly.Registry;
using Mapster;
using MapsterMapper;
using NexusReader.Data.Persistence;
namespace NexusReader.Application.Queries.Library; namespace NexusReader.Application.Queries.Library;
@@ -21,21 +10,11 @@ public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId,
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>> public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
{ {
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator; private readonly IKnowledgeService _knowledgeService;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ResiliencePipeline _retryPipeline;
private readonly IMapper _mapper;
public SearchLibrarySemanticallyQueryHandler( public SearchLibrarySemanticallyQueryHandler(IKnowledgeService knowledgeService)
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
IDbContextFactory<AppDbContext> dbContextFactory,
ResiliencePipelineProvider<string> pipelineProvider,
IMapper mapper)
{ {
_embeddingGenerator = embeddingGenerator; _knowledgeService = knowledgeService;
_dbContextFactory = dbContextFactory;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_mapper = mapper;
} }
public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken) public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken)
@@ -45,19 +24,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
return Result.Fail("Query text cannot be empty."); return Result.Fail("Query text cannot be empty.");
} }
// Generate embedding with retry return await _knowledgeService.SearchLibrarySemanticallyAsync(
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct => request.QueryText,
await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: ct), cancellationToken); request.TenantId,
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray()); request.Limit,
cancellationToken);
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var cacheEntries = await dbContext.SemanticKnowledgeCache
.Where(c => c.TenantId == request.TenantId && c.Embedding != null)
.OrderBy(c => c.Embedding!.CosineDistance(queryVector))
.Take(request.Limit)
.ToListAsync(cancellationToken);
var dtos = _mapper.Map<List<SemanticSearchResultDto>>(cacheEntries);
return Result.Ok(dtos);
} }
} }
@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using FluentResults;
using MediatR;
namespace NexusReader.Application.Queries.Recommendations;
/// <summary>
/// MediatR query to fetch contextual recommendations based on the user's active reading state.
/// </summary>
public record GetContextualRecommendationsQuery(string UserId)
: IRequest<Result<ContextualRecommendationResponse>>;
/// <summary>
/// Response DTO containing contextual recommendations.
/// </summary>
public record ContextualRecommendationResponse(List<RecommendationDto> Recommendations);
/// <summary>
/// Individual contextual recommendation details.
/// </summary>
public record RecommendationDto(
string BookTitle,
string ChapterTitle,
int MatchPercentage,
bool IsPremiumUpsell,
Guid TargetBookId
);
@@ -18,13 +18,16 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) public async Task<Result<UserProfileDto>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
{ {
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var profile = await dbContext.Users
var userRaw = await dbContext.Users
.Where(u => u.Id == request.UserId) .Where(u => u.Id == request.UserId)
.Select(u => new UserProfileDto .Select(u => new
{ {
Email = u.Email ?? string.Empty, Email = u.Email ?? string.Empty,
UserId = u.Id,
AITokensUsed = u.AITokensUsed, AITokensUsed = u.AITokensUsed,
TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty, TenantIdString = u.TenantId,
ThemePreference = u.ThemePreference,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{ {
Id = u.SubscriptionPlan.Id, Id = u.SubscriptionPlan.Id,
@@ -32,9 +35,17 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
AITokenLimit = u.SubscriptionPlan.AITokenLimit, AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(), } : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any(q => q.TotalQuestions > 0) QuizResults = u.QuizResults.Select(q => new
? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100) {
: 0, q.Score,
q.TotalQuestions,
q.Id,
q.Topic,
q.Percentage,
q.CompletedDate
}).ToList(),
DisplayName = u.DisplayName,
BooksReadCount = u.Ebooks.Count(),
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{ {
Id = e.Id, Id = e.Id,
@@ -48,7 +59,8 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
Progress = e.Progress, Progress = e.Progress,
LastChapter = e.LastChapter ?? "Rozpoczynanie...", LastChapter = e.LastChapter ?? "Rozpoczynanie...",
LastChapterIndex = e.LastChapterIndex, LastChapterIndex = e.LastChapterIndex,
Description = e.Description Description = e.Description,
IsReadyForReading = e.IsReadyForReading
}).FirstOrDefault(), }).FirstOrDefault(),
Roles = dbContext.UserRoles Roles = dbContext.UserRoles
.Where(ur => ur.UserId == u.Id) .Where(ur => ur.UserId == u.Id)
@@ -57,11 +69,60 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (profile == null) if (userRaw == null)
{ {
return Result.Fail("Profile not found."); return Result.Fail("Profile not found.");
} }
var tenantId = userRaw.TenantIdString;
var mappedConcepts = await dbContext.KnowledgeUnits
.Where(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))
.OrderByDescending(k => k.CreatedAt)
.Take(6)
.Select(k => new MappedConceptDto
{
Id = k.Id,
Type = k.Type.ToString(),
Content = k.Content
})
.ToListAsync(cancellationToken);
var conceptsMappedCount = await dbContext.KnowledgeUnits
.CountAsync(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId), cancellationToken);
int averageQuizScore = 0;
var validQuizzes = userRaw.QuizResults.Where(q => q.TotalQuestions > 0).ToList();
if (validQuizzes.Count > 0)
{
averageQuizScore = (int)(validQuizzes.Average(q => (double)q.Score / q.TotalQuestions) * 100);
}
var profile = new UserProfileDto
{
Email = userRaw.Email,
UserId = userRaw.UserId,
AITokensUsed = userRaw.AITokensUsed,
TenantId = userRaw.TenantIdString != null && userRaw.TenantIdString.Length == 36 ? new Guid(userRaw.TenantIdString) : Guid.Empty,
Plan = userRaw.Plan,
AverageQuizScore = averageQuizScore,
DisplayName = userRaw.DisplayName,
BooksReadCount = userRaw.BooksReadCount,
ThemePreference = userRaw.ThemePreference,
ConceptsMappedCount = conceptsMappedCount,
LastReadBook = userRaw.LastReadBook,
RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto
{
Id = q.Id,
Topic = q.Topic,
Score = q.Score,
TotalQuestions = q.TotalQuestions,
Percentage = q.Percentage,
CompletedDate = q.CompletedDate
}).ToList(),
MappedConcepts = mappedConcepts,
Roles = userRaw.Roles
};
return Result.Ok(profile); return Result.Ok(profile);
} }
} }
@@ -0,0 +1,19 @@
namespace NexusReader.Application.Utilities;
/// <summary>
/// Shared utility for parsing numeric segment identifiers from IDs like "seg-42".
/// Centralizes the parsing contract to avoid duplication across handlers and UI components.
/// </summary>
public static class SegmentIdParser
{
/// <summary>
/// Extracts the numeric portion from a segment identifier string (e.g., "seg-42" → 42).
/// Returns 0 if the string is null, empty, or contains no digits.
/// </summary>
public static int Parse(string? id)
{
if (string.IsNullOrEmpty(id)) return 0;
var digits = new string(id.Where(char.IsDigit).ToArray());
return int.TryParse(digits, out var val) ? val : 0;
}
}
@@ -0,0 +1,711 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NexusReader.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260607104453_AddThemePreference")]
partial class AddThemePreference
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Authors");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("AuthorId")
.HasColumnType("integer");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsReadyForReading")
.HasColumnType("boolean");
b.Property<string>("LastChapter")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("LastChapterIndex")
.HasColumnType("integer");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp with time zone");
b.Property<double>("Progress")
.HasColumnType("double precision");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Property<string>("Id")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("EbookId")
.HasColumnType("uuid");
b.Property<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("EbookId");
b.HasIndex("TenantId");
b.ToTable("KnowledgeUnits");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("RelationType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("SourceUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TargetUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("SourceUnitId");
b.HasIndex("TargetUnitId");
b.ToTable("KnowledgeUnitLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<int>("AITokensUsed")
.HasColumnType("integer");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<DateTime?>("LastAiActionDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastReadPageId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<int>("SubscriptionPlanId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("ThemePreference")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.HasIndex("SubscriptionPlanId");
b.HasIndex("TenantId");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.Property<int>("TotalQuestions")
.HasColumnType("integer");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("QuizResults");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
{
b.Property<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("ContentHash");
b.HasIndex("ContentHash")
.IsUnique();
b.HasIndex("TenantId");
b.ToTable("SemanticKnowledgeCache");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<bool>("IsUnlimitedTokens")
.HasColumnType("boolean");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("PlanName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("PlanName")
.IsUnique();
b.ToTable("SubscriptionPlans");
b.HasData(
new
{
Id = 1,
AITokenLimit = 5000,
IsUnlimitedTokens = false,
MonthlyPrice = 0m,
PlanName = "Free",
StripeProductId = "prod_Free789"
},
new
{
Id = 2,
AITokenLimit = 10000,
IsUnlimitedTokens = false,
MonthlyPrice = 9.99m,
PlanName = "Basic",
StripeProductId = "prod_basic_placeholder"
},
new
{
Id = 3,
AITokenLimit = 50000,
IsUnlimitedTokens = false,
MonthlyPrice = 19.99m,
PlanName = "Pro",
StripeProductId = "prod_pro_placeholder"
},
new
{
Id = 4,
AITokenLimit = 1000000000,
IsUnlimitedTokens = true,
MonthlyPrice = 99.99m,
PlanName = "Enterprise",
StripeProductId = "prod_enterprise_placeholder"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
.WithMany("Ebooks")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
.WithMany()
.HasForeignKey("EbookId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Ebook");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
.WithMany("OutgoingLinks")
.HasForeignKey("SourceUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
.WithMany("IncomingLinks")
.HasForeignKey("TargetUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SourceUnit");
b.Navigation("TargetUnit");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
.WithMany()
.HasForeignKey("SubscriptionPlanId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SubscriptionPlan");
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("QuizResults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
{
b.Navigation("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
b.Navigation("OutgoingLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
b.Navigation("QuizResults");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Pgvector;
#nullable disable
namespace NexusReader.Data.Migrations
{
/// <inheritdoc />
public partial class AddThemePreference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Vector",
table: "SemanticKnowledgeCache");
migrationBuilder.DropColumn(
name: "Vector",
table: "KnowledgeUnits");
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.AddColumn<int>(
name: "ThemePreference",
table: "AspNetUsers",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThemePreference",
table: "AspNetUsers");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.AddColumn<Vector>(
name: "Vector",
table: "SemanticKnowledgeCache",
type: "vector(1536)",
nullable: true);
migrationBuilder.AddColumn<Vector>(
name: "Vector",
table: "KnowledgeUnits",
type: "vector(768)",
nullable: true);
}
}
}
@@ -0,0 +1,865 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NexusReader.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260611183927_AddBookVersioningSupport")]
partial class AddBookVersioningSupport
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Authors");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CurrentDraftRevisionId")
.HasColumnType("uuid");
b.Property<Guid?>("LivePublishedRevisionId")
.HasColumnType("uuid");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CurrentDraftRevisionId");
b.HasIndex("LivePublishedRevisionId");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Books");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BookId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VersionString")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("BookId");
b.ToTable("BookRevisions");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BookRevisionId")
.HasColumnType("uuid");
b.Property<string>("MarkdownContent")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("BookRevisionId");
b.ToTable("Chapters");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("AuthorId")
.HasColumnType("integer");
b.Property<string>("CoverUrl")
.HasColumnType("text");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsReadyForReading")
.HasColumnType("boolean");
b.Property<string>("LastChapter")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("LastChapterIndex")
.HasColumnType("integer");
b.Property<DateTime?>("LastReadDate")
.HasColumnType("timestamp with time zone");
b.Property<double>("Progress")
.HasColumnType("double precision");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Property<string>("Id")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("EbookId")
.HasColumnType("uuid");
b.Property<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("EbookId");
b.HasIndex("TenantId");
b.ToTable("KnowledgeUnits");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("RelationType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("SourceUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TargetUnitId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("SourceUnitId");
b.HasIndex("TargetUnitId");
b.ToTable("KnowledgeUnitLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<int>("AITokensUsed")
.HasColumnType("integer");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<DateTime?>("LastAiActionDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastReadPageId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<int>("SubscriptionPlanId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("ThemePreference")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.HasIndex("SubscriptionPlanId");
b.HasIndex("TenantId");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CompletedDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.Property<int>("TotalQuestions")
.HasColumnType("integer");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("QuizResults");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b =>
{
b.Property<string>("ContentHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("JsonData")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ModelId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PromptVersion")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("ContentHash");
b.HasIndex("ContentHash")
.IsUnique();
b.HasIndex("TenantId");
b.ToTable("SemanticKnowledgeCache");
});
modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AITokenLimit")
.HasColumnType("integer");
b.Property<bool>("IsUnlimitedTokens")
.HasColumnType("boolean");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("PlanName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("PlanName")
.IsUnique();
b.ToTable("SubscriptionPlans");
b.HasData(
new
{
Id = 1,
AITokenLimit = 5000,
IsUnlimitedTokens = false,
MonthlyPrice = 0m,
PlanName = "Free",
StripeProductId = "prod_Free789"
},
new
{
Id = 2,
AITokenLimit = 10000,
IsUnlimitedTokens = false,
MonthlyPrice = 9.99m,
PlanName = "Basic",
StripeProductId = "prod_basic_placeholder"
},
new
{
Id = 3,
AITokenLimit = 50000,
IsUnlimitedTokens = false,
MonthlyPrice = 19.99m,
PlanName = "Pro",
StripeProductId = "prod_pro_placeholder"
},
new
{
Id = 4,
AITokenLimit = 1000000000,
IsUnlimitedTokens = true,
MonthlyPrice = 99.99m,
PlanName = "Enterprise",
StripeProductId = "prod_enterprise_placeholder"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
.WithMany()
.HasForeignKey("CurrentDraftRevisionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
.WithMany()
.HasForeignKey("LivePublishedRevisionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CurrentDraftRevision");
b.Navigation("LivePublishedRevision");
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
.WithMany("Revisions")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
{
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
.WithMany("Chapters")
.HasForeignKey("BookRevisionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BookRevision");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{
b.HasOne("NexusReader.Domain.Entities.Author", "Author")
.WithMany("Ebooks")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("Ebooks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook")
.WithMany()
.HasForeignKey("EbookId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Ebook");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b =>
{
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit")
.WithMany("OutgoingLinks")
.HasForeignKey("SourceUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit")
.WithMany("IncomingLinks")
.HasForeignKey("TargetUnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SourceUnit");
b.Navigation("TargetUnit");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan")
.WithMany()
.HasForeignKey("SubscriptionPlanId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SubscriptionPlan");
});
modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b =>
{
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany("QuizResults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Author", b =>
{
b.Navigation("Ebooks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.Navigation("Revisions");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.Navigation("Chapters");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{
b.Navigation("IncomingLinks");
b.Navigation("OutgoingLinks");
});
modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b =>
{
b.Navigation("Ebooks");
b.Navigation("QuizResults");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NexusReader.Data.Migrations
{
/// <inheritdoc />
public partial class AddBookVersioningSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BookRevisions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BookId = table.Column<Guid>(type: "uuid", nullable: false),
VersionString = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
IsPublished = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
PublishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BookRevisions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Books",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
TenantId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
UserId = table.Column<string>(type: "text", nullable: false),
CurrentDraftRevisionId = table.Column<Guid>(type: "uuid", nullable: true),
LivePublishedRevisionId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Books", x => x.Id);
table.ForeignKey(
name: "FK_Books_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Books_BookRevisions_CurrentDraftRevisionId",
column: x => x.CurrentDraftRevisionId,
principalTable: "BookRevisions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Books_BookRevisions_LivePublishedRevisionId",
column: x => x.LivePublishedRevisionId,
principalTable: "BookRevisions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Chapters",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BookRevisionId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
MarkdownContent = table.Column<string>(type: "text", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapters", x => x.Id);
table.ForeignKey(
name: "FK_Chapters_BookRevisions_BookRevisionId",
column: x => x.BookRevisionId,
principalTable: "BookRevisions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BookRevisions_BookId",
table: "BookRevisions",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_Books_CurrentDraftRevisionId",
table: "Books",
column: "CurrentDraftRevisionId");
migrationBuilder.CreateIndex(
name: "IX_Books_LivePublishedRevisionId",
table: "Books",
column: "LivePublishedRevisionId");
migrationBuilder.CreateIndex(
name: "IX_Books_TenantId",
table: "Books",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_Books_UserId",
table: "Books",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Chapters_BookRevisionId",
table: "Chapters",
column: "BookRevisionId");
migrationBuilder.AddForeignKey(
name: "FK_BookRevisions_Books_BookId",
table: "BookRevisions",
column: "BookId",
principalTable: "Books",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BookRevisions_Books_BookId",
table: "BookRevisions");
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "Books");
migrationBuilder.DropTable(
name: "BookRevisions");
}
}
}
@@ -5,7 +5,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NexusReader.Data.Persistence; using NexusReader.Data.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
#nullable disable #nullable disable
@@ -21,7 +20,6 @@ namespace NexusReader.Data.Migrations
.HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@@ -174,6 +172,103 @@ namespace NexusReader.Data.Migrations
b.ToTable("Authors"); b.ToTable("Authors");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CurrentDraftRevisionId")
.HasColumnType("uuid");
b.Property<Guid?>("LivePublishedRevisionId")
.HasColumnType("uuid");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CurrentDraftRevisionId");
b.HasIndex("LivePublishedRevisionId");
b.HasIndex("TenantId");
b.HasIndex("UserId");
b.ToTable("Books");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BookId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VersionString")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("BookId");
b.ToTable("BookRevisions");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BookRevisionId")
.HasColumnType("uuid");
b.Property<string>("MarkdownContent")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("BookRevisionId");
b.ToTable("Chapters");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -264,9 +359,6 @@ namespace NexusReader.Data.Migrations
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<Vector>("Vector")
.HasColumnType("vector(768)");
b.Property<string>("Version") b.Property<string>("Version")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
@@ -388,6 +480,11 @@ namespace NexusReader.Data.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)"); .HasColumnType("character varying(128)");
b.Property<int>("ThemePreference")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<bool>("TwoFactorEnabled") b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -480,9 +577,6 @@ namespace NexusReader.Data.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)"); .HasColumnType("character varying(128)");
b.Property<Vector>("Vector")
.HasColumnType("vector(1536)");
b.HasKey("ContentHash"); b.HasKey("ContentHash");
b.HasIndex("ContentHash") b.HasIndex("ContentHash")
@@ -617,6 +711,53 @@ namespace NexusReader.Data.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.HasOne("NexusReader.Domain.Entities.BookRevision", "CurrentDraftRevision")
.WithMany()
.HasForeignKey("CurrentDraftRevisionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NexusReader.Domain.Entities.BookRevision", "LivePublishedRevision")
.WithMany()
.HasForeignKey("LivePublishedRevisionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NexusReader.Domain.Entities.NexusUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CurrentDraftRevision");
b.Navigation("LivePublishedRevision");
b.Navigation("User");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.HasOne("NexusReader.Domain.Entities.Book", "Book")
.WithMany("Revisions")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Chapter", b =>
{
b.HasOne("NexusReader.Domain.Entities.BookRevision", "BookRevision")
.WithMany("Chapters")
.HasForeignKey("BookRevisionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BookRevision");
});
modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b =>
{ {
b.HasOne("NexusReader.Domain.Entities.Author", "Author") b.HasOne("NexusReader.Domain.Entities.Author", "Author")
@@ -692,6 +833,16 @@ namespace NexusReader.Data.Migrations
b.Navigation("Ebooks"); b.Navigation("Ebooks");
}); });
modelBuilder.Entity("NexusReader.Domain.Entities.Book", b =>
{
b.Navigation("Revisions");
});
modelBuilder.Entity("NexusReader.Domain.Entities.BookRevision", b =>
{
b.Navigation("Chapters");
});
modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b =>
{ {
b.Navigation("IncomingLinks"); b.Navigation("IncomingLinks");
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Domain.Enums;
namespace NexusReader.Data.Persistence; namespace NexusReader.Data.Persistence;
@@ -24,6 +25,9 @@ public class AppDbContext : IdentityDbContext<NexusUser>
public DbSet<QuizResult> QuizResults => Set<QuizResult>(); public DbSet<QuizResult> QuizResults => Set<QuizResult>();
public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>(); public DbSet<SubscriptionPlan> SubscriptionPlans => Set<SubscriptionPlan>();
public DbSet<Author> Authors => Set<Author>(); public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
public DbSet<BookRevision> BookRevisions => Set<BookRevision>();
public DbSet<Chapter> Chapters => Set<Chapter>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -43,6 +47,10 @@ public class AppDbContext : IdentityDbContext<NexusUser>
// Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed) // Note: DefaultValue for int is 1 (which corresponds to 'Free' in our seed)
entity.Property(u => u.SubscriptionPlanId) entity.Property(u => u.SubscriptionPlanId)
.HasDefaultValue(1); .HasDefaultValue(1);
entity.Property(u => u.ThemePreference)
.HasConversion<int>()
.HasDefaultValue(ThemeMode.System);
}); });
modelBuilder.Entity<SubscriptionPlan>(entity => modelBuilder.Entity<SubscriptionPlan>(entity =>
@@ -55,16 +63,7 @@ public class AppDbContext : IdentityDbContext<NexusUser>
entity.HasKey(e => e.ContentHash); entity.HasKey(e => e.ContentHash);
entity.HasIndex(e => e.ContentHash).IsUnique(); entity.HasIndex(e => e.ContentHash).IsUnique();
entity.HasIndex(e => e.TenantId); entity.HasIndex(e => e.TenantId);
if (Database.IsNpgsql()) entity.Ignore(e => e.Embedding);
{
// Configure vector column (768 dims) and HNSW index for cosine similarity
entity.Property(e => e.Embedding).HasColumnType("vector(768)");
entity.HasIndex(e => e.Embedding).HasMethod("hnsw").HasOperators("vector_cosine_ops");
}
else
{
entity.Ignore(e => e.Embedding);
}
}); });
modelBuilder.Entity<KnowledgeUnit>(entity => modelBuilder.Entity<KnowledgeUnit>(entity =>
@@ -118,6 +117,48 @@ public class AppDbContext : IdentityDbContext<NexusUser>
entity.HasIndex(e => e.TenantId); entity.HasIndex(e => e.TenantId);
}); });
modelBuilder.Entity<Book>(entity =>
{
entity.HasKey(b => b.Id);
entity.HasIndex(b => b.TenantId);
entity.HasIndex(b => b.UserId);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(b => b.Revisions)
.WithOne(r => r.Book)
.HasForeignKey(r => r.BookId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.CurrentDraftRevision)
.WithMany()
.HasForeignKey(b => b.CurrentDraftRevisionId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(b => b.LivePublishedRevision)
.WithMany()
.HasForeignKey(b => b.LivePublishedRevisionId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<BookRevision>(entity =>
{
entity.HasKey(r => r.Id);
entity.HasMany(r => r.Chapters)
.WithOne(c => c.BookRevision)
.HasForeignKey(c => c.BookRevisionId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Chapter>(entity =>
{
entity.HasKey(c => c.Id);
});
// Seed Subscription Plans with deterministic IDs // Seed Subscription Plans with deterministic IDs
modelBuilder.Entity<SubscriptionPlan>().HasData( modelBuilder.Entity<SubscriptionPlan>().HasData(
new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" }, new SubscriptionPlan { Id = 1, PlanName = SubscriptionPlan.FreeName, AITokenLimit = 5000, IsUnlimitedTokens = false, MonthlyPrice = 0m, StripeProductId = "prod_Free789" },
@@ -1,7 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Pgvector.EntityFrameworkCore;
namespace NexusReader.Data.Persistence; namespace NexusReader.Data.Persistence;
@@ -38,7 +37,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres"; connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres";
} }
optionsBuilder.UseNpgsql(connectionString, x => x.UseVector()); optionsBuilder.UseNpgsql(connectionString);
return new AppDbContext(optionsBuilder.Options); return new AppDbContext(optionsBuilder.Options);
} }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using System; using System;
using System.Linq; using System.Linq;
@@ -16,6 +17,7 @@ public static class DbInitializer
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>(); var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>(); var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
var configuration = scope.ServiceProvider.GetService<IConfiguration>();
using var dbContext = await dbContextFactory.CreateDbContextAsync(); using var dbContext = await dbContextFactory.CreateDbContextAsync();
try try
@@ -68,7 +70,31 @@ public static class DbInitializer
SecurityStamp = Guid.NewGuid().ToString() SecurityStamp = Guid.NewGuid().ToString()
}; };
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!"); var adminPassword = configuration?["Nexus:AdminPassword"]
?? configuration?["NEXUS_ADMIN_PASSWORD"]
?? Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD");
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? "Development";
var isDevelopment = string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(adminPassword))
{
if (!isDevelopment)
{
throw new InvalidOperationException(
"CRITICAL SECURITY ERROR: Admin password is NOT configured! " +
"In non-Development environments (e.g. Test/Production), the admin password must be explicitly set " +
"via configuration ('Nexus:AdminPassword' or 'NEXUS_ADMIN_PASSWORD') or environment variables. " +
"Seeding aborted to prevent insecure credentials fallback.");
}
Console.WriteLine("[Seeder] WARNING: Admin password is not set. Falling back to default weak password 'Admin123!' in Development environment.");
adminPassword = "Admin123!";
}
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
dbContext.Users.Add(adminUser); dbContext.Users.Add(adminUser);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@@ -110,6 +136,72 @@ public static class DbInitializer
{ {
Console.WriteLine("[Seeder] Admin user already exists."); Console.WriteLine("[Seeder] Admin user already exists.");
} }
// Seed Sample Authored Book for Creator Dashboard
var activeAdmin = await dbContext.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail);
if (activeAdmin != null)
{
if (!dbContext.Books.Any(b => b.UserId == activeAdmin.Id))
{
var sampleBookId = Guid.NewGuid();
var sampleBook = new Book
{
Id = sampleBookId,
Title = "Przewodnik po platformie Nexus",
UserId = activeAdmin.Id,
TenantId = activeAdmin.TenantId ?? "global"
};
dbContext.Books.Add(sampleBook);
await dbContext.SaveChangesAsync();
var sampleRevisionId = Guid.NewGuid();
var sampleRevision = new BookRevision
{
Id = sampleRevisionId,
BookId = sampleBookId,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
dbContext.BookRevisions.Add(sampleRevision);
await dbContext.SaveChangesAsync();
var sampleChapter1 = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = sampleRevisionId,
Title = "Rozdział 1: Wprowadzenie do Zen Mode",
MarkdownContent = @"# Zen Mode Editor
Welcome to your dedicated workspace. This premium panel supports Notion-like WYSIWYG editing.
## Features:
- **Zero Distraction**: Simple elevation and border framing.
- **GFM Tables**: Consistent cell padding and hover striping.
- **Clean Code Blocks**: Pre-rendered base64 font-loaded code-preview blocks.",
SortOrder = 1
};
var sampleChapter2 = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = sampleRevisionId,
Title = "Rozdział 2: Zabezpieczenia i XSS",
MarkdownContent = @"# Security Overview
This module provides Magic Number image signature checking and HtmlSanitizer filters.",
SortOrder = 2
};
dbContext.Chapters.Add(sampleChapter1);
dbContext.Chapters.Add(sampleChapter2);
await dbContext.SaveChangesAsync();
sampleBook.CurrentDraftRevisionId = sampleRevisionId;
await dbContext.SaveChangesAsync();
Console.WriteLine("[Seeder] Sample authored book and chapters seeded for admin.");
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
+39
View File
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Represents a Book metadata entry that references its decoupled revisions.
/// </summary>
public class Book
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(255)]
public string Title { get; set; } = string.Empty;
[Required]
[MaxLength(128)]
public string TenantId { get; set; } = "global";
[Required]
public string UserId { get; set; } = string.Empty;
[ForeignKey(nameof(UserId))]
public virtual NexusUser? User { get; set; }
public Guid? CurrentDraftRevisionId { get; set; }
[ForeignKey(nameof(CurrentDraftRevisionId))]
public virtual BookRevision? CurrentDraftRevision { get; set; }
public Guid? LivePublishedRevisionId { get; set; }
[ForeignKey(nameof(LivePublishedRevisionId))]
public virtual BookRevision? LivePublishedRevision { get; set; }
public virtual ICollection<BookRevision> Revisions { get; set; } = new List<BookRevision>();
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Encapsulates a snapshot or draft version of a Book's chapters.
/// </summary>
public class BookRevision
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public Guid BookId { get; set; }
[ForeignKey(nameof(BookId))]
public virtual Book Book { get; set; } = null!;
[Required]
[MaxLength(100)]
public string VersionString { get; set; } = "Working Draft";
public bool IsPublished { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? PublishedAt { get; set; }
public virtual ICollection<Chapter> Chapters { get; set; } = new List<Chapter>();
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities;
/// <summary>
/// Represents a chapter belonging strictly to a specific BookRevision.
/// </summary>
public class Chapter
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public Guid BookRevisionId { get; set; }
[ForeignKey(nameof(BookRevisionId))]
public virtual BookRevision BookRevision { get; set; } = null!;
[Required]
[MaxLength(255)]
public string Title { get; set; } = string.Empty;
[Required]
public string MarkdownContent { get; set; } = string.Empty;
[Required]
public int SortOrder { get; set; }
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using NexusReader.Domain.Enums;
namespace NexusReader.Domain.Entities; namespace NexusReader.Domain.Entities;
@@ -65,4 +66,9 @@ public class NexusUser : IdentityUser
/// Last read timestamp. /// Last read timestamp.
/// </summary> /// </summary>
public DateTime? LastReadAt { get; set; } public DateTime? LastReadAt { get; set; }
/// <summary>
/// User's visual theme preference.
/// </summary>
public ThemeMode ThemePreference { get; set; } = ThemeMode.System;
} }
@@ -0,0 +1,8 @@
namespace NexusReader.Domain.Enums;
public enum ThemeMode
{
System = 0,
Dark = 1,
LightSepia = 2
}
@@ -0,0 +1,12 @@
namespace NexusReader.Domain.Exceptions;
/// <summary>
/// Custom domain exception thrown when a Book cannot be found by its ID.
/// </summary>
public class BookNotFoundException : Exception
{
public BookNotFoundException(Guid bookId)
: base($"Book with ID '{bookId}' was not found.")
{
}
}
@@ -0,0 +1,35 @@
using System.Collections.Generic;
namespace NexusReader.Infrastructure.Configuration;
/// <summary>
/// Settings for configuring allowed tags, attributes, CSS properties, and schemes in HtmlSanitizerService.
/// </summary>
public class HtmlSanitizerSettings
{
public const string SectionName = "HtmlSanitizer";
/// <summary>
/// Gets or sets the list of HTML tags that are allowed.
/// If null or empty, the default allowed tags list is used.
/// </summary>
public List<string>? AllowedTags { get; set; }
/// <summary>
/// Gets or sets the list of HTML attributes that are allowed.
/// If null or empty, the default allowed attributes list is used.
/// </summary>
public List<string>? AllowedAttributes { get; set; }
/// <summary>
/// Gets or sets the list of CSS properties that are allowed.
/// If null or empty, the default allowed CSS properties list is used.
/// </summary>
public List<string>? AllowedCssProperties { get; set; }
/// <summary>
/// Gets or sets the list of URI schemes that are allowed (e.g. "http", "https").
/// If null or empty, the default allowed schemes list is used.
/// </summary>
public List<string>? AllowedSchemes { get; set; }
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using NexusReader.Application.Common;
using GeminiDotnet; using GeminiDotnet;
using GeminiDotnet.Extensions.AI; using GeminiDotnet.Extensions.AI;
using NexusReader.Data.Persistence; using NexusReader.Data.Persistence;
@@ -54,11 +55,24 @@ public static class DependencyInjection
// Qdrant Client registration // Qdrant Client registration
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334"; var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl))); var qdrantApiKey = configuration["Qdrant:ApiKey"];
services.AddSingleton<QdrantClient>(sp =>
{
if (!string.IsNullOrEmpty(qdrantApiKey))
{
return new QdrantClient(new Uri(qdrantUrl), apiKey: qdrantApiKey);
}
return new QdrantClient(new Uri(qdrantUrl));
});
// Neo4j Driver registration // Neo4j Driver registration (supports optional authentication)
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687"; var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, AuthTokens.None)); var neo4jUser = configuration["Neo4j:Username"];
var neo4jPass = configuration["Neo4j:Password"];
var neo4jAuth = !string.IsNullOrEmpty(neo4jUser)
? AuthTokens.Basic(neo4jUser, neo4jPass ?? string.Empty)
: AuthTokens.None;
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, neo4jAuth));
// Hangfire registration // Hangfire registration
if (!string.IsNullOrEmpty(pgConnectionString)) if (!string.IsNullOrEmpty(pgConnectionString))
@@ -71,6 +85,8 @@ public static class DependencyInjection
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName)); services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName)); services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
services.Configure<HtmlSanitizerSettings>(configuration.GetSection(HtmlSanitizerSettings.SectionName));
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings(); var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER") if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
@@ -112,13 +128,21 @@ public static class DependencyInjection
services.AddScoped<IKnowledgeService, KnowledgeService>(); services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddTransient<IEpubReader, EpubReaderService>(); services.AddTransient<IEpubReader, EpubReaderService>();
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>(); services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
services.AddTransient<IEpubExtractor, EpubExtractor>();
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution // Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
// that is environment-specific and incompatible with Singleton lifetime in MAUI. // that is environment-specific and incompatible with Singleton lifetime in MAUI.
services.AddScoped<IBookStorageService, BookStorageService>(); services.AddScoped<IBookStorageService, BookStorageService>();
services.AddScoped<IStorageService, LocalStorageService>();
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>(); services.AddScoped<IEbookRepository, EbookRepository>();
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
services.AddScoped<IUserReadingStateStore, UserReadingStateStore>();
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper) // Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>(); services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
@@ -28,6 +28,8 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Polly" /> <PackageReference Include="Polly" />
<PackageReference Include="Polly.Extensions.Http" /> <PackageReference Include="Polly.Extensions.Http" />
<PackageReference Include="HtmlSanitizer" />
<PackageReference Include="Markdig" />
<PackageReference Include="Qdrant.Client" /> <PackageReference Include="Qdrant.Client" />
<PackageReference Include="Stripe.net" /> <PackageReference Include="Stripe.net" />
<PackageReference Include="VersOne.Epub" /> <PackageReference Include="VersOne.Epub" />
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Infrastructure.Persistence;
/// <summary>
/// EF Core implementation of <see cref="IConceptsMapReadRepository"/>.
/// Uses <see cref="IDbContextFactory{TContext}"/> for Blazor-safe scoped context creation.
/// </summary>
internal sealed class ConceptsMapReadRepository : IConceptsMapReadRepository
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public ConceptsMapReadRepository(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public async Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var user = await dbContext.Users
.Where(u => u.Id == userId)
.Select(u => new { u.LastReadPageId })
.FirstOrDefaultAsync(cancellationToken);
return user?.LastReadPageId;
}
/// <inheritdoc />
public async Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(
Guid bookId,
string tenantId,
CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.KnowledgeUnits
.Where(k => k.EbookId == bookId &&
(k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)))
.OrderBy(k => k.CreatedAt)
.ToListAsync(cancellationToken);
}
}
@@ -46,6 +46,12 @@ internal sealed class EbookRepository : IEbookRepository
_context.Ebooks.Add(ebook); _context.Ebooks.Add(ebook);
} }
/// <inheritdoc />
public async Task<Ebook?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Ebooks.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc /> /// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> _context.SaveChangesAsync(cancellationToken); => _context.SaveChangesAsync(cancellationToken);
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
namespace NexusReader.Infrastructure.Persistence;
/// <summary>
/// EF Core implementation of <see cref="IQuizResultRepository"/>.
/// </summary>
internal sealed class QuizResultRepository : IQuizResultRepository
{
private readonly AppDbContext _context;
public QuizResultRepository(AppDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<NexusUser?> FindUserByIdAsync(string userId, CancellationToken cancellationToken = default)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
}
/// <inheritdoc />
public void AddQuizResult(QuizResult quizResult)
{
_context.QuizResults.Add(quizResult);
}
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return _context.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Data.Persistence;
namespace NexusReader.Infrastructure.Persistence;
/// <summary>
/// EF Core implementation of <see cref="IUserLibraryStore"/> using <see cref="AppDbContext"/>.
/// </summary>
internal sealed class UserLibraryStore : IUserLibraryStore
{
private readonly AppDbContext _context;
public UserLibraryStore(AppDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
{
return await _context.Ebooks
.Where(e => e.UserId == userId)
.Select(e => e.Id)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
{
if (bookIds == null || !bookIds.Any())
{
return new Dictionary<Guid, string>();
}
return await _context.Ebooks
.Where(e => bookIds.Contains(e.Id))
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
}
}
@@ -0,0 +1,56 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Data.Persistence;
namespace NexusReader.Infrastructure.Persistence;
/// <summary>
/// EF Core implementation of <see cref="IUserReadingStateStore"/>.
/// </summary>
internal sealed class UserReadingStateStore : IUserReadingStateStore
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public UserReadingStateStore(IDbContextFactory<AppDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public async Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var userState = await dbContext.Users
.Where(u => u.Id == userId)
.Select(u => new
{
u.TenantId,
u.LastReadPageId,
LastReadBookId = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => (Guid?)e.Id).FirstOrDefault()
})
.FirstOrDefaultAsync(cancellationToken);
if (userState == null)
{
return (null, null, null);
}
return (userState.LastReadBookId, userState.LastReadPageId, userState.TenantId);
}
/// <inheritdoc />
public async Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.KnowledgeUnits
.Where(ku => ku.Id == chapterId)
.Select(ku => ku.Content)
.FirstOrDefaultAsync(cancellationToken);
}
}
@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Qdrant.Client;
using Qdrant.Client.Grpc;
using Polly;
using Polly.Registry;
using NexusReader.Application.Abstractions.Persistence;
namespace NexusReader.Infrastructure.Persistence;
/// <summary>
/// Infrastructure implementation of <see cref="IVectorSearchStore"/> utilizing <see cref="QdrantClient"/>
/// and <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> to execute semantic vector queries.
/// </summary>
internal sealed class VectorSearchStore : IVectorSearchStore
{
private readonly QdrantClient _qdrantClient;
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
private readonly ResiliencePipeline _retryPipeline;
private readonly ILogger<VectorSearchStore> _logger;
public VectorSearchStore(
QdrantClient qdrantClient,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
ResiliencePipelineProvider<string> pipelineProvider,
ILogger<VectorSearchStore> logger)
{
_qdrantClient = qdrantClient;
_embeddingGenerator = embeddingGenerator;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_logger = logger;
}
/// <inheritdoc />
public async Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
{
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
var filter = BuildTenantFilter(tenantId);
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
}
/// <inheritdoc />
public async Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
{
if (whitelistedBookIds == null || !whitelistedBookIds.Any())
{
return new List<VectorChunk>();
}
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
var filter = BuildTenantFilter(tenantId);
var whitelistFilter = new Qdrant.Client.Grpc.Filter();
foreach (var bookId in whitelistedBookIds)
{
whitelistFilter.Should.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "ebookId",
Match = new Qdrant.Client.Grpc.Match { Text = bookId.ToString() }
}
});
}
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = whitelistFilter });
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
}
/// <inheritdoc />
public async Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default)
{
var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken);
var filter = BuildTenantFilter(tenantId);
// Exclude current book
filter.MustNot.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "ebookId",
Match = new Qdrant.Client.Grpc.Match { Text = excludeBookId.ToString() }
}
});
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
}
private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("[VectorSearchStore] Attempted to generate embedding from empty text. Returning zero vector.");
return Array.Empty<float>();
}
var sw = Stopwatch.StartNew();
var response = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(
new[] { text },
new EmbeddingGenerationOptions { Dimensions = 768 },
cancellationToken: ct), cancellationToken);
sw.Stop();
_logger.LogDebug("[VectorSearchStore] Embedding generated in {ElapsedMs}ms for text of {Length} chars.", sw.ElapsedMilliseconds, text.Length);
return response.First().Vector.ToArray();
}
private Qdrant.Client.Grpc.Filter BuildTenantFilter(string tenantId)
{
var filter = new Qdrant.Client.Grpc.Filter();
var tenantFilter = new Qdrant.Client.Grpc.Filter();
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "tenantId",
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
}
});
tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "tenantId",
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
}
});
filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = tenantFilter });
return filter;
}
private async Task<List<VectorChunk>> ExecuteSearchAsync(float[] queryVector, Qdrant.Client.Grpc.Filter filter, int limit, CancellationToken cancellationToken)
{
if (queryVector.Length == 0)
{
_logger.LogWarning("[VectorSearchStore] Empty query vector — skipping Qdrant search.");
return new List<VectorChunk>();
}
try
{
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var sw = Stopwatch.StartNew();
var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units",
vector: queryVector,
filter: filter,
limit: (ulong)limit,
cancellationToken: cancellationToken
);
sw.Stop();
_logger.LogInformation("[VectorSearchStore] Qdrant search returned {Count} results in {ElapsedMs}ms.", response.Count, sw.ElapsedMilliseconds);
return response.Select(point =>
{
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
var ebookId = point.Payload.TryGetValue("ebookId", out var ev) ? ev.StringValue : string.Empty;
var metadataJson = point.Payload.TryGetValue("metadataJson", out var mv) ? mv.StringValue : string.Empty;
var bookTitle = point.Payload.TryGetValue("bookTitle", out var btv) ? btv.StringValue : string.Empty;
var chapterTitle = point.Payload.TryGetValue("chapterTitle", out var ctv) ? ctv.StringValue : string.Empty;
return new VectorChunk(content, ebookId, point.Score, metadataJson, bookTitle, chapterTitle);
}).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "[VectorSearchStore] Qdrant search execution failed.");
throw;
}
}
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken)
{
try
{
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
if (!exists)
{
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' does not exist — creating.", collectionName);
await _qdrantClient.CreateCollectionAsync(
collectionName: collectionName,
vectorsConfig: new Qdrant.Client.Grpc.VectorParams
{
Size = 768,
Distance = Distance.Cosine
},
cancellationToken: cancellationToken
);
_logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName);
}
}
catch (Exception ex)
{
// Log concurrent creation conflicts (e.g., AlreadyExists gRPC status) but do not propagate.
_logger.LogWarning(ex, "[VectorSearchStore] Non-fatal error while ensuring collection '{CollectionName}' exists. Possible concurrent creation.", collectionName);
}
}
}
@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentResults;
using MediatR;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Persistence;
using NexusReader.Application.Queries.Recommendations;
namespace NexusReader.Infrastructure.Queries;
/// <summary>
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
/// performing semantic search using <see cref="IVectorSearchStore"/> with book exclusion, and mapping upsells.
/// </summary>
public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>>
{
private readonly IUserReadingStateStore _readingStateStore;
private readonly IUserLibraryStore _libraryStore;
private readonly IVectorSearchStore _vectorSearchStore;
private readonly ILogger<GetContextualRecommendationsQueryHandler> _logger;
/// <summary>
/// Initializes a new instance of <see cref="GetContextualRecommendationsQueryHandler"/>.
/// </summary>
public GetContextualRecommendationsQueryHandler(
IUserReadingStateStore readingStateStore,
IUserLibraryStore libraryStore,
IVectorSearchStore vectorSearchStore,
ILogger<GetContextualRecommendationsQueryHandler> logger)
{
_readingStateStore = readingStateStore;
_libraryStore = libraryStore;
_vectorSearchStore = vectorSearchStore;
_logger = logger;
}
/// <inheritdoc />
public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.UserId))
{
return Result.Fail("UserId cannot be empty.");
}
try
{
// Step 1: Discover active reading state
var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken);
if (ebookId == null)
{
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId);
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
}
// Step 2: Fetch specific content associated with active ChapterId
string? chapterContent = null;
if (!string.IsNullOrEmpty(chapterId))
{
chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken);
}
// Guard: empty chapter content cannot produce a meaningful embedding
if (string.IsNullOrWhiteSpace(chapterContent))
{
_logger.LogWarning("[Recommendations] Chapter content is empty for chapterId={ChapterId}. Returning empty list.", chapterId);
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
}
// Step 3: Perform similarity search using IVectorSearchStore
var resolvedTenantId = tenantId ?? "global";
_logger.LogDebug("[Recommendations] Performing vector search for user {UserId}, book {EbookId}, tenant {TenantId}.", request.UserId, ebookId, resolvedTenantId);
var searchResults = await _vectorSearchStore.SearchGlobalExcludeAsync(
chapterContent,
resolvedTenantId,
ebookId.Value,
limit: 2,
cancellationToken: cancellationToken
);
// Step 4: Process recommendations and cross-reference owned books
var ownedBookIds = await _libraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken);
var recommendations = new List<RecommendationDto>();
foreach (var point in searchResults)
{
var targetEbookIdStr = point.EbookId;
if (!Guid.TryParse(targetEbookIdStr, out var targetEbookId))
continue;
// Load bookTitle from point
var bookTitle = point.BookTitle;
if (string.IsNullOrEmpty(bookTitle))
{
bookTitle = "Nieznana książka";
}
// Load chapterTitle from point or metadataJson
var chapterTitle = point.ChapterTitle;
if (string.IsNullOrEmpty(chapterTitle))
{
chapterTitle = "Wiedza z rozdziału";
if (!string.IsNullOrEmpty(point.MetadataJson))
{
try
{
using var doc = JsonDocument.Parse(point.MetadataJson);
if (doc.RootElement.TryGetProperty("label", out var labelProp))
{
chapterTitle = labelProp.GetString() ?? chapterTitle;
}
}
catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
}
}
}
var isPremiumUpsell = !ownedBookIds.Contains(targetEbookId);
var matchPercentage = (int)Math.Round(point.Score * 100);
recommendations.Add(new RecommendationDto(
BookTitle: bookTitle,
ChapterTitle: chapterTitle,
MatchPercentage: matchPercentage,
IsPremiumUpsell: isPremiumUpsell,
TargetBookId: targetEbookId
));
}
_logger.LogInformation("[Recommendations] Returning {Count} recommendations for user {UserId}.", recommendations.Count, request.UserId);
return Result.Ok(new ContextualRecommendationResponse(recommendations));
}
catch (Exception ex)
{
_logger.LogError(ex, "[Recommendations] Downstream vector database or state query failed for user {UserId}.", request.UserId);
return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex));
}
}
}
@@ -0,0 +1,85 @@
using System.Text.RegularExpressions;
using FluentResults;
using Microsoft.Extensions.Logging;
using NexusReader.Application.Abstractions.Services;
using VersOne.Epub;
namespace NexusReader.Infrastructure.Services;
public class EpubExtractor : IEpubExtractor
{
private readonly ILogger<EpubExtractor> _logger;
public EpubExtractor(ILogger<EpubExtractor> logger)
{
_logger = logger;
}
public async Task<Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default)
{
try
{
var fullPath = ResolvePath(relativePath);
if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath))
{
_logger.LogError("[EpubExtractor] EPUB file not found at path: {FilePath}", relativePath);
return Result.Fail<List<string>>($"Plik EPUB nie został znaleziony na dysku: {relativePath}");
}
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
var readingOrder = bookRef.GetReadingOrder();
if (readingOrder == null || !readingOrder.Any())
{
return Result.Fail<List<string>>("EPUB nie zawiera czytelnych rozdziałów.");
}
var chapters = new List<string>();
foreach (var chapterRef in readingOrder)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var rawContent = await chapterRef.ReadContentAsTextAsync();
var cleanText = StripHtml(rawContent);
chapters.Add(cleanText);
}
return Result.Ok(chapters);
}
catch (Exception ex)
{
_logger.LogError(ex, "[EpubExtractor] Error extracting chapters from EPUB: {FilePath}", relativePath);
return Result.Fail<List<string>>(new Error("Failed to parse and extract text from EPUB").CausedBy(ex));
}
}
private static string? ResolvePath(string relativePath)
{
var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar);
var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
while (currentDir != null)
{
var candidate = Path.Combine(currentDir.FullName, "wwwroot", normalized);
if (File.Exists(candidate)) return candidate;
var devCandidate = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", "wwwroot", normalized);
if (File.Exists(devCandidate)) return devCandidate;
currentDir = currentDir.Parent;
}
return null;
}
private static string StripHtml(string html)
{
if (string.IsNullOrEmpty(html)) return string.Empty;
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
clean = Regex.Replace(clean, @"<[^>]*>", " ");
clean = System.Net.WebUtility.HtmlDecode(clean);
clean = Regex.Replace(clean, @"\s+", " ").Trim();
return clean;
}
}
@@ -18,6 +18,16 @@ public class EpubReaderService : IEpubReader
private readonly ILogger<EpubReaderService> _logger; private readonly ILogger<EpubReaderService> _logger;
private const int WordThreshold = 1000; private const int WordThreshold = 1000;
private static readonly Regex ImageTagRegex = new(@"<img\b(?<before>[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex BodyMatchRegex = new(@"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>|<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex WhitelistTagsRegex = new(@"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr|img)\b)[^>]+>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex StripAttributesRegex = new(@"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?<alt>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public EpubReaderService( public EpubReaderService(
IDbContextFactory<AppDbContext> dbContextFactory, IDbContextFactory<AppDbContext> dbContextFactory,
ILogger<EpubReaderService> logger) ILogger<EpubReaderService> logger)
@@ -80,6 +90,9 @@ public class EpubReaderService : IEpubReader
var chapterContent = await chapterRef.ReadContentAsTextAsync(); var chapterContent = await chapterRef.ReadContentAsTextAsync();
// Rewrite relative image src URLs to use the server-side API endpoint
chapterContent = RewriteImageUrls(chapterContent, ebookId, chapterRef.FilePath);
// 3. Build content blocks // 3. Build content blocks
var blocks = new List<ContentBlock>(); var blocks = new List<ContentBlock>();
int totalWordCount = 0; int totalWordCount = 0;
@@ -142,13 +155,150 @@ public class EpubReaderService : IEpubReader
return null; return null;
} }
/// <inheritdoc />
public async Task<Result<byte[]>> GetEpubResourceAsync(
Guid ebookId,
string resourcePath,
string? userId = null,
CancellationToken cancellationToken = default)
{
try
{
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var ebook = await context.Ebooks
.AsNoTracking()
.FirstOrDefaultAsync(
e => e.Id == ebookId && (userId == null || e.UserId == userId),
cancellationToken);
if (ebook == null)
{
return Result.Fail($"Ebook '{ebookId}' not found.");
}
var fullPath = ResolvePath(ebook.FilePath);
if (fullPath == null || !File.Exists(fullPath))
{
return Result.Fail("EPUB file not found.");
}
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
var decodedPath = System.Net.WebUtility.UrlDecode(resourcePath);
if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
{
return Result.Fail("Invalid resource path.");
}
decodedPath = decodedPath.Replace('\\', '/').TrimStart('/');
EpubLocalContentFileRef? targetFile = null;
if (bookRef.Content?.AllFiles?.Local != null)
{
foreach (var file in bookRef.Content.AllFiles.Local)
{
var filePath = file.FilePath?.Replace('\\', '/').TrimStart('/') ?? "";
var fileKey = file.Key?.Replace('\\', '/').TrimStart('/') ?? "";
if (filePath.Equals(decodedPath, StringComparison.OrdinalIgnoreCase) ||
fileKey.Equals(decodedPath, StringComparison.OrdinalIgnoreCase))
{
targetFile = file;
break;
}
}
}
if (targetFile != null)
{
if (targetFile is EpubLocalByteContentFileRef byteFile)
{
byte[] bytes = await byteFile.ReadContentAsync();
return Result.Ok(bytes);
}
else if (targetFile is EpubLocalTextContentFileRef textFile)
{
string text = await textFile.ReadContentAsync();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(text);
return Result.Ok(bytes);
}
}
return Result.Fail($"Resource '{resourcePath}' not found in EPUB.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve EPUB resource '{ResourcePath}' for ebook {EbookId}.", resourcePath, ebookId);
return Result.Fail(new Error($"Failed to retrieve EPUB resource: {ex.Message}").CausedBy(ex));
}
}
private static string RewriteImageUrls(string html, Guid ebookId, string chapterPath)
{
if (string.IsNullOrEmpty(html)) return html;
return ImageTagRegex.Replace(html, match =>
{
var rawSrc = match.Groups["src"].Value;
if (rawSrc.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
{
return ""; // Completely block script execution in image src
}
if (rawSrc.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
rawSrc.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
rawSrc.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
return match.Value;
}
var resolvedPath = ResolveRelativePath(chapterPath, rawSrc);
var rewrittenSrc = $"/api/epub/{ebookId}/resource?path={System.Net.WebUtility.UrlEncode(resolvedPath)}";
return $"{match.Groups["before"].Value}{rewrittenSrc}{match.Groups["after"].Value}";
});
}
private static string ResolveRelativePath(string basePath, string relativePath)
{
if (string.IsNullOrEmpty(relativePath)) return string.Empty;
var decodedRelative = System.Net.WebUtility.UrlDecode(relativePath);
var baseDir = Path.GetDirectoryName(basePath) ?? "";
baseDir = baseDir.Replace('\\', '/');
var combined = Path.Combine(baseDir, decodedRelative).Replace('\\', '/');
var segments = combined.Split('/');
var stack = new Stack<string>();
foreach (var segment in segments)
{
if (segment == "." || string.IsNullOrEmpty(segment))
{
continue;
}
if (segment == "..")
{
if (stack.Count > 0)
{
stack.Pop();
}
}
else
{
stack.Push(segment);
}
}
return string.Join("/", stack.Reverse());
}
private static List<string> ExtractParagraphs(string html) private static List<string> ExtractParagraphs(string html)
{ {
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline); var bodyMatch = BodyMatchRegex.Match(html);
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html; var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
var paragraphs = new List<string>(); var paragraphs = new List<string>();
var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline); var matches = ParagraphMatchRegex.Matches(content);
foreach (Match match in matches) foreach (Match match in matches)
{ {
@@ -165,9 +315,20 @@ public class EpubReaderService : IEpubReader
private static string SanitizeParagraph(string html) private static string SanitizeParagraph(string html)
{ {
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); var clean = StyleScriptRegex.Replace(html, "");
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase); clean = WhitelistTagsRegex.Replace(clean, "");
clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase); clean = StripAttributesRegex.Replace(clean, "<$1>");
// Securely sanitize img tags by keeping ONLY src and alt attributes to prevent XSS (onerror, onload, style, etc.)
clean = ImgTagSanitizerRegex.Replace(clean, m =>
{
var srcMatch = SrcAttributeRegex.Match(m.Value);
var altMatch = AltAttributeRegex.Match(m.Value);
var srcAttr = srcMatch.Success ? $" src=\"{srcMatch.Groups["src"].Value}\"" : "";
var altAttr = altMatch.Success ? $" alt=\"{altMatch.Groups["alt"].Value}\"" : "";
return $"<img{srcAttr}{altAttr} />";
});
clean = System.Net.WebUtility.HtmlDecode(clean); clean = System.Net.WebUtility.HtmlDecode(clean);
return clean.Trim(); return clean.Trim();
} }
@@ -0,0 +1,76 @@
using Ganss.Xss;
using Microsoft.Extensions.Options;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Configuration;
using Markdig;
namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Infrastructure implementation of ISanitizerService using the Ganss.Xss HtmlSanitizer library.
/// </summary>
public class HtmlSanitizerService : ISanitizerService
{
private readonly HtmlSanitizer _sanitizer;
private readonly MarkdownPipeline _pipeline;
public HtmlSanitizerService(IOptions<HtmlSanitizerSettings>? options = null)
{
_sanitizer = new HtmlSanitizer();
_pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
if (options?.Value != null)
{
var settings = options.Value;
if (settings.AllowedTags != null && settings.AllowedTags.Count > 0)
{
_sanitizer.AllowedTags.Clear();
foreach (var tag in settings.AllowedTags)
{
_sanitizer.AllowedTags.Add(tag);
}
}
if (settings.AllowedAttributes != null && settings.AllowedAttributes.Count > 0)
{
_sanitizer.AllowedAttributes.Clear();
foreach (var attr in settings.AllowedAttributes)
{
_sanitizer.AllowedAttributes.Add(attr);
}
}
if (settings.AllowedCssProperties != null && settings.AllowedCssProperties.Count > 0)
{
_sanitizer.AllowedCssProperties.Clear();
foreach (var prop in settings.AllowedCssProperties)
{
_sanitizer.AllowedCssProperties.Add(prop);
}
}
if (settings.AllowedSchemes != null && settings.AllowedSchemes.Count > 0)
{
_sanitizer.AllowedSchemes.Clear();
foreach (var scheme in settings.AllowedSchemes)
{
_sanitizer.AllowedSchemes.Add(scheme);
}
}
}
}
public string Sanitize(string input)
{
if (string.IsNullOrEmpty(input))
{
return input;
}
// Translate raw Markdown input to HTML strictly before running HtmlSanitizer
var html = Markdown.ToHtml(input, _pipeline);
return _sanitizer.Sanitize(html).Trim();
}
}
@@ -4,6 +4,8 @@ using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MediatR;
using NexusReader.Application.Queries.Intelligence;
using Microsoft.ML.Tokenizers; using Microsoft.ML.Tokenizers;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI; using NexusReader.Application.DTOs.AI;
@@ -15,6 +17,7 @@ using Polly.Registry;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NexusReader.Infrastructure.Configuration; using NexusReader.Infrastructure.Configuration;
using Qdrant.Client; using Qdrant.Client;
using Qdrant.Client.Grpc;
using Neo4j.Driver; using Neo4j.Driver;
namespace NexusReader.Infrastructure.Services; namespace NexusReader.Infrastructure.Services;
@@ -32,8 +35,10 @@ public class KnowledgeService : IKnowledgeService
private readonly ILogger<KnowledgeService> _logger; private readonly ILogger<KnowledgeService> _logger;
private readonly QdrantClient _qdrantClient; private readonly QdrantClient _qdrantClient;
private readonly IDriver _neo4jDriver; private readonly IDriver _neo4jDriver;
private const string PromptVersion = "1.3"; private readonly IMediator _mediator;
private const string PromptVersion = "1.7";
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new(); private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
public KnowledgeService( public KnowledgeService(
IChatClient chatClient, IChatClient chatClient,
@@ -43,7 +48,8 @@ public class KnowledgeService : IKnowledgeService
IOptions<AiSettings> settings, IOptions<AiSettings> settings,
ILogger<KnowledgeService> logger, ILogger<KnowledgeService> logger,
QdrantClient qdrantClient, QdrantClient qdrantClient,
IDriver neo4jDriver) IDriver neo4jDriver,
IMediator mediator)
{ {
_chatClient = chatClient; _chatClient = chatClient;
_embeddingGenerator = embeddingGenerator; _embeddingGenerator = embeddingGenerator;
@@ -53,6 +59,7 @@ public class KnowledgeService : IKnowledgeService
_logger = logger; _logger = logger;
_qdrantClient = qdrantClient; _qdrantClient = qdrantClient;
_neo4jDriver = neo4jDriver; _neo4jDriver = neo4jDriver;
_mediator = mediator;
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides // Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
// a very reliable estimation for token usage in Gemini-based workloads. // a very reliable estimation for token usage in Gemini-based workloads.
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
@@ -84,11 +91,12 @@ public class KnowledgeService : IKnowledgeService
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var normalizedText = text.Trim(); var normalizedText = text.Trim();
var hash = ContentHasher.ComputeHash(normalizedText); var hashInput = $"{normalizedText}:{traceType}:{PromptVersion}";
var hash = ContentHasher.ComputeHash(hashInput);
// 1. Check Cache // 1. Check Cache
var cached = await dbContext.SemanticKnowledgeCache var cached = await dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken); .FirstOrDefaultAsync(c => c.ContentHash == hash, cancellationToken);
if (cached != null && cached.PromptVersion == PromptVersion) if (cached != null && cached.PromptVersion == PromptVersion)
{ {
@@ -96,7 +104,12 @@ public class KnowledgeService : IKnowledgeService
try try
{ {
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions); var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions);
if (packet != null) return Result.Ok(packet); if (packet != null)
{
await ProcessKnowledgeUnitsAsync(packet, tenantId, ebookId, dbContext, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok(packet);
}
} }
catch (JsonException ex) catch (JsonException ex)
{ {
@@ -105,7 +118,7 @@ public class KnowledgeService : IKnowledgeService
} }
// Deduplicate concurrent active requests for the exact same hash // Deduplicate concurrent active requests for the exact same hash
var requestKey = $"{tenantId}:{hash}:{traceType}"; var requestKey = $"{hash}:{traceType}";
var lazyTask = _activeRequests.GetOrAdd(requestKey, k => var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
new Lazy<Task<Result<KnowledgePacket>>>( new Lazy<Task<Result<KnowledgePacket>>>(
@@ -177,7 +190,7 @@ public class KnowledgeService : IKnowledgeService
// 4. Save to Cache // 4. Save to Cache
var cached = await dbContext.SemanticKnowledgeCache var cached = await dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId); .FirstOrDefaultAsync(c => c.ContentHash == hash);
var cacheEntry = new SemanticKnowledgeCache var cacheEntry = new SemanticKnowledgeCache
{ {
@@ -201,7 +214,14 @@ public class KnowledgeService : IKnowledgeService
// 5. Process structured KnowledgeUnits (Graph Expansion) // 5. Process structured KnowledgeUnits (Graph Expansion)
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default); await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
await dbContext.SaveChangesAsync(); try
{
await dbContext.SaveChangesAsync();
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pgEx && pgEx.SqlState == "23505")
{
_logger.LogWarning("[KnowledgeService] Concurrency collision on SemanticKnowledgeCache for {Hash}; another process saved it first. Swallowing.", hash);
}
return Result.Ok(knowledgePacket); return Result.Ok(knowledgePacket);
} }
catch (JsonException ex) catch (JsonException ex)
@@ -224,6 +244,30 @@ public class KnowledgeService : IKnowledgeService
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken) private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken)
{ {
if (packet.Graph != null && (packet.Units == null || !packet.Units.Any()))
{
var graphUnits = packet.Graph.Nodes.Select(node => new KnowledgeUnitDto(
node.Id,
node.Type ?? "concept",
node.Description ?? node.Label,
new Dictionary<string, object>
{
["label"] = node.Label,
["group"] = node.Group,
["summary"] = node.Summary ?? "",
["key_terms"] = node.KeyTerms ?? new List<string>()
}
)).ToList();
var graphLinks = packet.Graph.Links.Select(link => new KnowledgeLinkDto(
link.Source,
link.Target,
link.RelationType
)).ToList();
packet = packet with { Units = graphUnits, Links = graphLinks };
}
var unitIds = packet.Units.Select(u => u.Id).ToList(); var unitIds = packet.Units.Select(u => u.Id).ToList();
var linkSourceIds = packet.Links.Select(l => l.Source).ToList(); var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
var linkTargetIds = packet.Links.Select(l => l.Target).ToList(); var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
@@ -285,6 +329,211 @@ public class KnowledgeService : IKnowledgeService
_logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target); _logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target);
} }
} }
// Generate and upsert vectors to Qdrant in batch
var unitsToEmbed = packet.Units
.Where(u => !string.IsNullOrEmpty(u.Content))
.ToList();
if (unitsToEmbed.Any())
{
try
{
// Retrieve the book's title from the database using EF Core
string bookTitle = "Nieznana książka";
if (ebookId.HasValue)
{
var ebook = await dbContext.Ebooks.FindAsync(new object[] { ebookId.Value }, cancellationToken);
if (ebook != null)
{
bookTitle = ebook.Title;
}
}
var contents = unitsToEmbed.Select(u => u.Content).ToList();
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(
contents,
new EmbeddingGenerationOptions { Dimensions = 768 },
cancellationToken: ct), cancellationToken);
var embeddings = embeddingResponse.ToList();
var points = new List<PointStruct>();
for (int i = 0; i < unitsToEmbed.Count; i++)
{
var unitDto = unitsToEmbed[i];
var vector = embeddings[i].Vector.ToArray();
string chapterTitle = "Wiedza z rozdziału";
if (unitDto.Metadata != null && unitDto.Metadata.TryGetValue("label", out var labelVal) && labelVal is string labelStr)
{
chapterTitle = labelStr;
}
var point = new PointStruct
{
Id = GetDeterministicGuid(unitDto.Id),
Vectors = vector,
Payload =
{
["content"] = unitDto.Content,
["type"] = unitDto.Type ?? string.Empty,
["tenantId"] = tenantId,
["ebookId"] = ebookId?.ToString() ?? string.Empty,
["bookTitle"] = bookTitle,
["chapterTitle"] = chapterTitle,
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
}
};
points.Add(point);
}
if (points.Any())
{
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
await _qdrantClient.UpsertAsync("knowledge_units", points, cancellationToken: cancellationToken);
_logger.LogInformation("[KnowledgeService] Successfully upserted {Count} points to Qdrant collection 'knowledge_units'.", points.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[KnowledgeService] Failed to generate and upsert embeddings for knowledge units to Qdrant.");
}
}
// 6. Synchronize to Neo4j graph database
await SyncToNeo4jAsync(packet, cancellationToken);
}
private async Task SyncToNeo4jAsync(KnowledgePacket packet, CancellationToken cancellationToken)
{
if (packet.Units == null || !packet.Units.Any()) return;
try
{
await using var session = _neo4jDriver.AsyncSession();
// 1. Merge nodes in a transaction
await session.ExecuteWriteAsync(async tx =>
{
foreach (var unit in packet.Units)
{
var cypher = @"
MERGE (u:KnowledgeUnit {id: $id})
ON CREATE SET u.content = $content, u.type = $type
ON MATCH SET u.content = $content, u.type = $type";
var guidStr = GetDeterministicGuid(unit.Id).ToString();
await tx.RunAsync(cypher, new
{
id = guidStr,
content = unit.Content ?? string.Empty,
type = unit.Type ?? "concept"
});
}
});
// 2. Merge links in a transaction
if (packet.Links != null && packet.Links.Any())
{
await session.ExecuteWriteAsync(async tx =>
{
foreach (var link in packet.Links)
{
if (string.IsNullOrWhiteSpace(link.Source) || string.IsNullOrWhiteSpace(link.Target))
continue;
var relationType = string.IsNullOrWhiteSpace(link.Relation) ? "RELATED_TO" : link.Relation.Trim().ToUpperInvariant();
relationType = System.Text.RegularExpressions.Regex.Replace(relationType, @"[^A-Z0-9_]", "_");
if (string.IsNullOrEmpty(relationType) || relationType == "_")
{
relationType = "RELATED_TO";
}
var cypher = $@"
MATCH (source:KnowledgeUnit {{id: $sourceId}})
MATCH (target:KnowledgeUnit {{id: $targetId}})
MERGE (source)-[r:{relationType}]->(target)";
var sourceGuidStr = GetDeterministicGuid(link.Source).ToString();
var targetGuidStr = GetDeterministicGuid(link.Target).ToString();
await tx.RunAsync(cypher, new
{
sourceId = sourceGuidStr,
targetId = targetGuidStr
});
}
});
}
_logger.LogInformation("[KnowledgeService] Successfully synchronized {NodeCount} nodes and {LinkCount} links to Neo4j.", packet.Units.Count, packet.Links?.Count ?? 0);
}
catch (Exception ex)
{
_logger.LogError(ex, "[KnowledgeService] Failed to synchronize knowledge graph to Neo4j.");
}
}
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
{
await _collectionSemaphore.WaitAsync(cancellationToken);
try
{
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
if (!exists)
{
_logger.LogInformation("[KnowledgeService] Creating Qdrant collection '{CollectionName}'...", collectionName);
await _qdrantClient.CreateCollectionAsync(
collectionName: collectionName,
vectorsConfig: new VectorParams
{
Size = 768,
Distance = Distance.Cosine
},
cancellationToken: cancellationToken
);
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' created successfully.", collectionName);
}
}
catch (Exception ex)
{
if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase) ||
(ex.InnerException != null && ex.InnerException.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)))
{
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' was already created by another thread.", collectionName);
}
else
{
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName);
}
}
finally
{
_collectionSemaphore.Release();
}
}
private static Guid GetDeterministicGuid(string input)
{
if (Guid.TryParse(input, out var guid))
{
return guid;
}
using var md5 = System.Security.Cryptography.MD5.Create();
byte[] hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
return new Guid(hash);
}
private static string GetPointIdString(PointId pointId)
{
if (pointId == null) return string.Empty;
return pointId.PointIdOptionsCase == PointId.PointIdOptionsOneofCase.Uuid
? pointId.Uuid
: pointId.Num.ToString();
} }
public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default) public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
@@ -354,6 +603,7 @@ public class KnowledgeService : IKnowledgeService
List<Qdrant.Client.Grpc.ScoredPoint> searchResult; List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try try
{ {
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync( var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units", collectionName: "knowledge_units",
vector: queryVector, vector: queryVector,
@@ -363,15 +613,37 @@ public class KnowledgeService : IKnowledgeService
); );
searchResult = response.ToList(); searchResult = response.ToList();
} }
catch (Exception) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during GetRelevantContextAsync. Returning empty search results.");
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>(); searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
} }
var contexts = searchResult.Select(point => new RelevantContext var contexts = searchResult.Select(point =>
{ {
Text = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty, var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
Confidence = point.Score var summary = string.Empty;
if (point.Payload.TryGetValue("metadataJson", out var metaVal) && !string.IsNullOrEmpty(metaVal.StringValue))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
if (meta != null && meta.TryGetValue("summary", out var sumObj))
{
summary = sumObj?.ToString();
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in RelevantContext mapping.");
}
}
var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}";
return new RelevantContext
{
Text = text,
Confidence = point.Score
};
}).ToList(); }).ToList();
return Result.Ok(contexts); return Result.Ok(contexts);
@@ -417,6 +689,7 @@ public class KnowledgeService : IKnowledgeService
List<Qdrant.Client.Grpc.ScoredPoint> searchResult; List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try try
{ {
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync( var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units", collectionName: "knowledge_units",
vector: queryVector, vector: queryVector,
@@ -438,7 +711,7 @@ public class KnowledgeService : IKnowledgeService
} }
// 3. Graph Expansion via Neo4j // 3. Graph Expansion via Neo4j
var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList(); var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList();
var definitions = new Dictionary<string, List<string>>(); var definitions = new Dictionary<string, List<string>>();
if (candidateIds.Any()) if (candidateIds.Any())
@@ -447,7 +720,7 @@ public class KnowledgeService : IKnowledgeService
{ {
await using var session = _neo4jDriver.AsyncSession(); await using var session = _neo4jDriver.AsyncSession();
var cypher = @" var cypher = @"
MATCH (source:KnowledgeUnit)-[r:DEFINES]->(target:KnowledgeUnit) MATCH (source:KnowledgeUnit)-[r]->(target:KnowledgeUnit)
WHERE source.id IN $candidateIds WHERE source.id IN $candidateIds
RETURN source.id AS sourceId, target.content AS targetContent"; RETURN source.id AS sourceId, target.content AS targetContent";
@@ -516,12 +789,15 @@ public class KnowledgeService : IKnowledgeService
{ {
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue); metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
} }
catch {} catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in search library mapping.");
}
} }
var dto = new SemanticSearchResultDto var dto = new SemanticSearchResultDto
{ {
ContentHash = point.Id.ToString(), ContentHash = GetPointIdString(point.Id),
Snippet = content, Snippet = content,
UnitType = type, UnitType = type,
RelevanceScore = point.Score, RelevanceScore = point.Score,
@@ -529,7 +805,7 @@ public class KnowledgeService : IKnowledgeService
Metadata = metadata Metadata = metadata
}; };
var pointIdStr = point.Id.ToString(); var pointIdStr = GetPointIdString(point.Id);
if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any()) if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any())
{ {
dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}"; dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}";
@@ -602,6 +878,7 @@ public class KnowledgeService : IKnowledgeService
List<Qdrant.Client.Grpc.ScoredPoint> searchResult; List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try try
{ {
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync( var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units", collectionName: "knowledge_units",
vector: queryVector, vector: queryVector,
@@ -627,11 +904,28 @@ public class KnowledgeService : IKnowledgeService
} }
// 3. Graph Expansion via Neo4j // 3. Graph Expansion via Neo4j
var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList(); var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList();
var relatedContexts = new List<string>(); var relatedContexts = new List<string>();
// Keep map of point ID -> payload data for fast mapping later // Keep map of point ID -> payload data for fast mapping later
var pointMap = searchResult.ToDictionary(r => r.Id.ToString(), r => r); var pointMap = searchResult.ToDictionary(r => GetPointIdString(r.Id), r => r);
// Fetch knowledge units from PostgreSQL to map Guids back to rich metadata summaries
var guidMap = new Dictionary<string, KnowledgeUnit>();
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var units = await dbContext.KnowledgeUnits
.Include(u => u.Ebook)
.ThenInclude(e => e.Author)
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
.ToListAsync(cancellationToken);
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to load KnowledgeUnits from PostgreSQL for Guid mapping.");
}
if (candidateIds.Any()) if (candidateIds.Any())
{ {
@@ -641,7 +935,7 @@ public class KnowledgeService : IKnowledgeService
var cypher = @" var cypher = @"
MATCH (source:KnowledgeUnit) MATCH (source:KnowledgeUnit)
WHERE source.id IN $candidateIds WHERE source.id IN $candidateIds
OPTIONAL MATCH (source)-[r:DEFINES|RELATED_TO]->(target:KnowledgeUnit) OPTIONAL MATCH (source)-[r]->(target:KnowledgeUnit)
RETURN source.id AS sourceId, source.content AS sourceContent, RETURN source.id AS sourceId, source.content AS sourceContent,
collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations"; collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations";
@@ -654,23 +948,70 @@ public class KnowledgeService : IKnowledgeService
foreach (var record in neoResult) foreach (var record in neoResult)
{ {
var sourceId = record["sourceId"].As<string>(); var sourceId = record["sourceId"].As<string>();
var sourceContent = record["sourceContent"].As<string>();
relatedContexts.Add($"[Source ID: {sourceId}] {sourceContent}"); var sourceText = string.Empty;
if (guidMap.TryGetValue(sourceId, out var sourceUnit))
{
var summary = string.Empty;
if (!string.IsNullOrEmpty(sourceUnit.MetadataJson))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(sourceUnit.MetadataJson);
if (meta != null && meta.TryGetValue("summary", out var sumObj))
{
summary = sumObj?.ToString();
}
}
catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync source hydration.", sourceUnit.Id);
}
}
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
}
else
{
sourceText = record["sourceContent"].As<string>();
}
relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}");
var relations = record["relations"].As<List<object>>(); var relations = record["relations"].As<List<object>>();
if (relations != null) if (relations != null)
{ {
foreach (var relObj in relations) foreach (var relObj in relations)
{ {
if (relObj is Dictionary<string, object> relDict && if (relObj is System.Collections.IDictionary relDict)
relDict.TryGetValue("targetId", out var targetIdVal) && targetIdVal is string targetId &&
relDict.TryGetValue("targetContent", out var targetContentVal) && targetContentVal is string targetContent &&
relDict.TryGetValue("relation", out var relationVal) && relationVal is string relation)
{ {
if (!string.IsNullOrEmpty(targetContent)) var targetId = relDict["targetId"]?.ToString();
var targetContent = relDict["targetContent"]?.ToString();
var relation = relDict["relation"]?.ToString();
if (!string.IsNullOrEmpty(targetContent) && !string.IsNullOrEmpty(relation))
{ {
relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetContent}"); var targetText = targetContent;
if (!string.IsNullOrEmpty(targetId) && guidMap.TryGetValue(targetId, out var targetUnit))
{
var summary = string.Empty;
if (!string.IsNullOrEmpty(targetUnit.MetadataJson))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(targetUnit.MetadataJson);
if (meta != null && meta.TryGetValue("summary", out var sumObj))
{
summary = sumObj?.ToString();
}
}
catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync target hydration.", targetUnit.Id);
}
}
targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}";
}
relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetText}");
} }
} }
} }
@@ -682,9 +1023,35 @@ public class KnowledgeService : IKnowledgeService
_logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points."); _logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points.");
foreach (var point in searchResult) foreach (var point in searchResult)
{ {
var sourceId = point.Id.ToString(); var sourceId = GetPointIdString(point.Id);
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
relatedContexts.Add($"[Source ID: {sourceId}] {content}"); var sourceText = string.Empty;
if (guidMap.TryGetValue(sourceId, out var sourceUnit))
{
var summary = string.Empty;
if (!string.IsNullOrEmpty(sourceUnit.MetadataJson))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(sourceUnit.MetadataJson);
if (meta != null && meta.TryGetValue("summary", out var sumObj))
{
summary = sumObj?.ToString();
}
}
catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in fallback AskQuestionAsync.", sourceUnit.Id);
}
}
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
}
else
{
sourceText = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
}
relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}");
} }
} }
} }
@@ -708,33 +1075,14 @@ public class KnowledgeService : IKnowledgeService
// 5. Build prompt and invoke Gemini with structured JSON formatting // 5. Build prompt and invoke Gemini with structured JSON formatting
var contextBlocksText = string.Join("\n\n", relatedContexts); var contextBlocksText = string.Join("\n\n", relatedContexts);
var systemPrompt = @" var systemPrompt = PromptRegistry.GroundedRAGSystemPrompt;
You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.
Strict Grounding Rules:
1. Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.
2. If the context does not contain the answer, you must state exactly: 'I cannot answer this based on the provided book context.'
3. For every statement or claim you make in your answer, you must cite the specific source IDs (e.g., source chunk ID or hash) from the context.
4. You must format your response ONLY as a JSON object matching the following structure:
{
""answer"": ""The answer text goes here, referencing [Source ID] as citations."",
""citations"": [
{
""citationId"": ""The exact source ID cited (e.g., chunk hash/ID)"",
""snippet"": ""The precise sentence or phrase from the context that supports this statement."",
""sourceBook"": ""The book title or 'Unknown'""
}
]
}
";
var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}"; var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}";
var options = new ChatOptions var options = new ChatOptions
{ {
Temperature = 0.0f, Temperature = 0.0f,
MaxOutputTokens = 1500, MaxOutputTokens = 1500
ResponseFormat = ChatResponseFormat.Json
}; };
var chatResponse = await _retryPipeline.ExecuteAsync(async ct => var chatResponse = await _retryPipeline.ExecuteAsync(async ct =>
@@ -746,6 +1094,20 @@ Strict Grounding Rules:
var rawJson = chatResponse.Text?.Trim() ?? string.Empty; var rawJson = chatResponse.Text?.Trim() ?? string.Empty;
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim(); rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
// Handle direct text fallback when model bypasses JSON format
if (!rawJson.StartsWith("{") &&
(rawJson.Contains("cannot answer", StringComparison.OrdinalIgnoreCase) ||
rawJson.Contains("context does not contain", StringComparison.OrdinalIgnoreCase) ||
rawJson.Contains("provided book context", StringComparison.OrdinalIgnoreCase)))
{
return Result.Ok(new GroundedResponseDto
{
Answer = "I cannot answer this based on the provided book context.",
Citations = new List<CitationDto>()
});
}
rawJson = JsonRepairHelper.Repair(rawJson); rawJson = JsonRepairHelper.Repair(rawJson);
try try
@@ -756,15 +1118,42 @@ Strict Grounding Rules:
return Result.Fail("Failed to deserialize grounded RAG response."); return Result.Fail("Failed to deserialize grounded RAG response.");
} }
// Hydrate book titles for citations if unknown // Hydrate book titles, author, and page number for citations if unknown
foreach (var citation in groundedResult.Citations) foreach (var citation in groundedResult.Citations)
{ {
if (pointMap.TryGetValue(citation.CitationId, out var point) && if (pointMap.TryGetValue(citation.CitationId, out var point) &&
point.Payload.TryGetValue("ebookId", out var ev) && point.Payload.TryGetValue("ebookId", out var ev) &&
Guid.TryParse(ev.StringValue, out var ebId) && Guid.TryParse(ev.StringValue, out var ebId))
ebookTitles.TryGetValue(ebId, out var title))
{ {
citation.SourceBook = title; if (ebookTitles.TryGetValue(ebId, out var title))
{
citation.SourceBook = title;
}
}
// Look up from guidMap to get exact page number and author
if (guidMap.TryGetValue(citation.CitationId, out var unit))
{
if (unit.Ebook?.Author != null)
{
citation.Author = unit.Ebook.Author.Name;
}
if (!string.IsNullOrEmpty(unit.MetadataJson))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(unit.MetadataJson);
if (meta != null && meta.TryGetValue("page", out var pageObj) && int.TryParse(pageObj?.ToString(), out var pageVal))
{
citation.PageNumber = pageVal;
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync citation mapping.", unit.Id);
}
}
} }
} }
@@ -790,6 +1179,30 @@ Strict Grounding Rules:
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken); await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken); await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken); await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
try
{
await _qdrantClient.DeleteCollectionAsync("knowledge_units", cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to drop Qdrant collection 'knowledge_units' during cache clear.");
}
try
{
await using var session = _neo4jDriver.AsyncSession();
await session.ExecuteWriteAsync(async tx =>
{
await tx.RunAsync("MATCH (n:KnowledgeUnit) DETACH DELETE n");
});
_logger.LogInformation("[KnowledgeService] Successfully wiped Neo4j 'KnowledgeUnit' nodes.");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to wipe Neo4j graph during cache clear.");
}
return Result.Ok(); return Result.Ok();
} }
catch (Exception ex) catch (Exception ex)
@@ -798,6 +1211,12 @@ Strict Grounding Rules:
} }
} }
/// <inheritdoc />
public async Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default)
{
return await _mediator.Send(new GetGlobalIntelligenceQuery(queryText, userId, tenantId), cancellationToken);
}
private int EstimateTokenCount(string text) private int EstimateTokenCount(string text)
{ {
if (string.IsNullOrEmpty(text)) return 0; if (string.IsNullOrEmpty(text)) return 0;
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Hosting;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Infrastructure implementation of general storage utilizing local filesystem.
/// Files are saved in wwwroot/uploads/media.
/// </summary>
public class LocalStorageService : IStorageService
{
private readonly IWebHostEnvironment _environment;
public LocalStorageService(IWebHostEnvironment environment)
{
_environment = environment;
}
public async Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType)
{
using var stream = new MemoryStream(fileBytes);
return await UploadFileAsync(stream, fileName, contentType);
}
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
{
var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads");
var resolvedMediaFolder = Path.GetFullPath(mediaFolder);
var folderWithSeparator = resolvedMediaFolder.EndsWith(Path.DirectorySeparatorChar)
? resolvedMediaFolder
: resolvedMediaFolder + Path.DirectorySeparatorChar;
if (!Directory.Exists(resolvedMediaFolder))
{
Directory.CreateDirectory(resolvedMediaFolder);
}
// Clean file name to prevent path traversal issues
var safeFileName = Path.GetFileName(fileName);
var uniqueFileName = $"{Guid.NewGuid()}_{safeFileName}";
var filePath = Path.Combine(resolvedMediaFolder, uniqueFileName);
// Guard against path traversal
var fullPath = Path.GetFullPath(filePath);
if (!fullPath.StartsWith(folderWithSeparator, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Path traversal detected.");
}
using (var outputStream = new FileStream(fullPath, FileMode.Create))
{
await fileStream.CopyToAsync(outputStream);
}
// Return the public web-relative URL
return $"/uploads/{uniqueFileName}";
}
}
@@ -4,9 +4,10 @@ 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). " + "**LANGUAGE CRITICAL**: Detect the language of the provided text. You MUST generate all human-readable fields ('title', 'description', 'question', 'options', 'label') in the EXACT SAME LANGUAGE as the source text. Do NOT translate them to English unless the source text is in English. " +
"CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' or its exact foreign equivalent, never full sentences). " +
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " + "CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " +
"CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node (e.g., 'Code Example'). Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " + "CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node with the label 'Code Example' translated to the detected language. Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " +
"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\" } ], " +
@@ -15,28 +16,66 @@ public static class PromptRegistry
"}."; "}.";
public const string GraphExtractionPrompt = public const string GraphExtractionPrompt =
"You are an expert at information architecture. Extract key concepts and paragraph mappings from the text to build a unified knowledge graph. " + "You are a strict Minimalist Information Architect. Your sole job is to build a high-level, sparse linear backbone for a textbook chapter. " +
"The input text consists of several paragraphs, each starting with its unique block ID in the format '[ID: seg-X]'. " + "**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'label', 'summary', and 'key_terms' fields MUST be in the EXACT SAME LANGUAGE as the source text. " +
"Extract two types of nodes: " + "The input text consists of sections starting with block IDs (e.g., '[ID: seg-4]'). " +
"1. Concept Nodes (group: 'concept'): Extract the main technical concepts discussed (e.g., ID: 'dependency-injection', label: 'Dependency Injection'). Max 10 concepts. Labels must be at most 3 words. " + "CRITICAL TOPOLOGY RULES (ZERO TOLERANCE FOR CLUTTER): " +
"2. Block Nodes (group: 'current'): For each paragraph in the input, create a node representing that paragraph where 'id' is the exact block ID (e.g., 'seg-1'), and 'label' is a brief summary of that paragraph's content (max 3 words). " + "1. HARD NODE LIMIT: You are strictly forbidden from extracting more than 4 to 5 nodes IN TOTAL for the entire text. If there are more sections, select ONLY the 4-5 absolute most critical, high-level structural pillars. " +
"CRITICAL: If a paragraph is a code block, represent it as a single block node with label 'Code Example' (group: 'current'). Do NOT extract low-level code elements (like variables, classes, methods, or namespaces) as separate concept nodes. " + "2. NO CONCEPT CLOUDS: Do NOT create nodes for individual technologies, files, terms, or phrases (e.g., 'Kestrel', 'appsettings.json', 'DI', 'Blazor Server' must NEVER be nodes). They must ONLY exist as text strings inside the 'key_terms' array of a major node. " +
"CRITICAL: Connect related concept nodes together, and connect each concept node to the block nodes ('seg-X') where it is discussed. " + "3. LINEAR SPINE PATTERN: Nodes must form a clear, clean path or simple tree representing the chronological reading journey (e.g., Node 1 -> Node 2 -> Node 3). Do NOT create complex web loops or interconnect every node. Limit total links in the entire JSON to maximum 4 or 5 links. " +
"Limit connections to a MAXIMUM of 15 most relevant links. " + "4. NODE DATA STRUCTURE: " +
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }"; " - 'id': must be the exact block ID (e.g., 'seg-16'). " +
" - 'label': clear technical title (Max 3 words, e.g., 'Blazor Hosting Models'). " +
" - 'group': strictly either 'bridge' (if it compares legacy vs modern) or 'concept' (for standalone core pillars). " +
" - 'summary': exact 2-sentence distillation for the Contextual Panel. " +
" - 'key_terms': array of max 5 short strings representing the micro-concepts hidden inside this section. " +
"System keys configuration: All JSON keys ('nodes', 'links', 'id', 'label', 'group', 'summary', 'key_terms', 'source', 'target', 'type') must remain strictly in English. " +
"Return ONLY minified JSON. Schema: " +
"{ " +
" \"graph\": { " +
" \"nodes\": [ " +
" { \"id\": \"seg-X\", \"label\": \"string\", \"group\": \"concept|bridge\", \"summary\": \"string\", \"key_terms\": [ \"string\" ] } " +
" ], " +
" \"links\": [ " +
" { \"source\": \"seg-X\", \"target\": \"seg-Y\", \"type\": \"maps_to|contains\" } " +
" ] " +
" } " +
"}";
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). " +
"**LANGUAGE CRITICAL**: Detect the language of the provided text. The generated 'summary', 'question', and 'options' MUST be in the EXACT SAME LANGUAGE as the source text. " +
"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 } ] }";
public const string KM_ExtractionPrompt = public const string KM_ExtractionPrompt =
"You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " + "You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " +
"**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'content' field MUST be in the EXACT SAME LANGUAGE as the source text. " +
"Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " + "Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " +
"CRITICAL: Units must be granular. " + "CRITICAL: Units must be granular. " +
"CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit. Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " + "CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit (translate the name to the detected language). Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " +
"CRITICAL SYSTEM VALUES: The fields 'type' (strictly: 'Section', 'Table', 'Definition', or 'Rule') and 'relation' (strictly: 'Next', 'Defines', 'Contains', or 'References') are system keys and MUST remain in English as specified. " +
"Schema: { " + "Schema: { " +
"\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " + "\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " +
"\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " + "\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " +
"}."; "}.";
public const string GroundedRAGSystemPrompt = """
You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.
Strict Grounding Rules:
1. Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.
2. If the context does not contain the answer, you must set the "answer" property in the JSON object exactly to: 'I cannot answer this based on the provided book context.' and the "citations" array must be empty.
3. For every statement or claim you make in your answer, you must cite the specific source IDs (e.g., source chunk ID or hash) from the context.
4. You must format your response ONLY as a JSON object matching the following structure:
{
"answer": "The answer text goes here, referencing [Source ID] as citations.",
"citations": [
{
"citationId": "The exact source ID cited (e.g., chunk hash/ID)",
"snippet": "The precise sentence or phrase from the context that supports this statement.",
"sourceBook": "The book title or 'Unknown'"
}
]
}
""";
} }
+18
View File
@@ -8,4 +8,22 @@ public partial class App : Microsoft.Maui.Controls.Application
MainPage = new MainPage(); MainPage = new MainPage();
} }
protected override Window CreateWindow(IActivationState? activationState)
{
var window = base.CreateWindow(activationState);
// Hook into native MAUI lifecycle events to cleanly flush and close Serilog buffers
window.Stopped += (s, e) =>
{
Serilog.Log.CloseAndFlush();
};
window.Destroying += (s, e) =>
{
Serilog.Log.CloseAndFlush();
};
return window;
}
} }
@@ -0,0 +1,150 @@
using System.Net.Http.Headers;
using System.Threading;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Maui.Infrastructure.Identity;
/// <summary>
/// A secure HTTP message delegating handler for MAUI that automatically appends JWT tokens
/// to trusted origin requests and transparently refreshes expired tokens in a thread-safe manner.
/// </summary>
public class MobileAuthenticationHeaderHandler : DelegatingHandler
{
private readonly INativeStorageService _storageService;
private readonly IServiceProvider _serviceProvider;
private const string TokenKey = "nexus_auth_token";
private static readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
public MobileAuthenticationHeaderHandler(INativeStorageService storageService, IServiceProvider serviceProvider)
{
_storageService = storageService;
_serviceProvider = serviceProvider;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? "";
bool isAuthEndpoint = path.Contains("identity/login") ||
path.Contains("identity/register") ||
path.Contains("identity/refresh");
// Resolve configured API host dynamically to avoid hardcoded IP addresses
var config = _serviceProvider.GetRequiredService<IConfiguration>();
var apiBaseUrlString = config["ApiSettings:BaseUrl"];
string? apiHost = null;
if (!string.IsNullOrEmpty(apiBaseUrlString) && Uri.TryCreate(apiBaseUrlString, UriKind.Absolute, out var apiUri))
{
apiHost = apiUri.Host;
}
// In MAUI, since we only call our own local or staging APIs, we trust local IP/localhost/configured API host.
// We ensure we don't accidentally leak tokens to third-party endpoints.
bool isTrustedHost = request.RequestUri != null &&
(request.RequestUri.Host == "localhost" ||
request.RequestUri.Host == "127.0.0.1" ||
(apiHost != null && request.RequestUri.Host == apiHost) ||
request.RequestUri.Host.EndsWith("nexusreader.com")); // Or staging domains
string? originalToken = null;
if (!isAuthEndpoint && isTrustedHost)
{
var tokenResult = await _storageService.GetSecureString(TokenKey);
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{
originalToken = tokenResult.Value;
// Only attach the Bearer token if it is not expired
if (!JwtTokenValidator.IsExpired(originalToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
}
}
}
var response = await base.SendAsync(request, cancellationToken);
// Transparent JWT Auto-Refresh on 401 Unauthorized
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint)
{
await _refreshSemaphore.WaitAsync(cancellationToken);
try
{
// Re-read token to verify if another concurrent request already refreshed it
var tokenResult = await _storageService.GetSecureString(TokenKey);
var currentToken = tokenResult.IsSuccess ? tokenResult.Value : null;
bool refreshed = false;
if (!string.IsNullOrEmpty(currentToken) && currentToken != originalToken)
{
refreshed = true;
}
else
{
using var scope = _serviceProvider.CreateScope();
var identityService = scope.ServiceProvider.GetRequiredService<IIdentityService>();
var refreshResult = await identityService.RefreshTokenAsync();
if (refreshResult.IsSuccess)
{
var newTokenResult = await _storageService.GetSecureString(TokenKey);
currentToken = newTokenResult.IsSuccess ? newTokenResult.Value : null;
refreshed = !string.IsNullOrEmpty(currentToken);
}
else
{
await identityService.LogoutAsync();
}
}
if (refreshed && !string.IsNullOrEmpty(currentToken))
{
var newRequest = await CloneHttpRequestMessageAsync(request);
newRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", currentToken);
return await base.SendAsync(newRequest, cancellationToken);
}
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "[MobileAuthHandler] Automated token renewal failed");
}
finally
{
_refreshSemaphore.Release();
}
}
return response;
}
private async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage req)
{
var clone = new HttpRequestMessage(req.Method, req.RequestUri)
{
Version = req.Version
};
if (req.Content != null)
{
var ms = new System.IO.MemoryStream();
await req.Content.CopyToAsync(ms);
ms.Position = 0;
clone.Content = new StreamContent(ms);
foreach (var h in req.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(h.Key, h.Value);
}
}
foreach (var h in req.Headers)
{
clone.Headers.TryAddWithoutValidation(h.Key, h.Value);
}
return clone;
}
}
@@ -0,0 +1,53 @@
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
namespace NexusReader.Maui.Infrastructure.Logging;
/// <summary>
/// Lightweight bridge service that intercepts logs, console outputs, and uncaught exceptions
/// from the Blazor WebView/JS side, and routes them directly to Serilog under "BlazorWebView" context.
/// </summary>
public sealed class BlazorLoggingBridge
{
private readonly ILogger _logger;
public BlazorLoggingBridge(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger("BlazorWebView");
}
[JSInvokable("LogJsMessage")]
public void LogJsMessage(string level, string message, string? stackTrace = null)
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
switch (level.ToLowerInvariant())
{
case "error":
case "exception":
if (!string.IsNullOrWhiteSpace(stackTrace))
{
_logger.LogError("JS Unhandled Exception: {Message}\nStack Trace:\n{StackTrace}", message, stackTrace);
}
else
{
_logger.LogError("JS Error: {Message}", message);
}
break;
case "warning":
case "warn":
_logger.LogWarning("JS Warning: {Message}", message);
break;
case "info":
case "log":
default:
_logger.LogInformation("JS Log: {Message}", message);
break;
}
}
}
@@ -0,0 +1,106 @@
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting;
using Serilog.Formatting.Display;
namespace NexusReader.Maui.Infrastructure.Logging;
public static class SerilogConfiguration
{
private const string OutputTemplate =
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
public static MauiAppBuilder RegisterLogging(this MauiAppBuilder builder)
{
// 1. Ensure logs directory exists in secure sandbox
var logDir = Path.Combine(Microsoft.Maui.Storage.FileSystem.AppDataDirectory, "logs");
if (!Directory.Exists(logDir))
{
Directory.CreateDirectory(logDir);
}
var logPath = Path.Combine(logDir, "log-.txt");
// 2. Inject sandboxed log path dynamically into configuration provider
builder.Configuration["Serilog:WriteTo:0:Args:configure:0:Args:path"] = logPath;
// 3. Configure Serilog Logger Configuration using App Configuration settings
var loggerConfig = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.With(new ThreadIdEnricher());
// 4. Platform-specific and environment-specific sinks
#if ANDROID
// Direct Native Android Logcat Sink (JNI bindings for native diagnostics)
loggerConfig.WriteTo.Sink(
new AndroidLogcatSink(new MessageTemplateTextFormatter(OutputTemplate, null)),
restrictedToMinimumLevel: LogEventLevel.Debug);
#endif
// 5. Initialize the static Serilog Log
Log.Logger = loggerConfig.CreateLogger();
// 6. Connect Serilog to Microsoft.Extensions.Logging
builder.Logging.ClearProviders();
builder.Logging.AddSerilog(dispose: true);
return builder;
}
}
/// <summary>
/// A custom self-contained thread enricher to avoid unnecessary NuGet packages.
/// </summary>
internal sealed class ThreadIdEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ThreadId", Environment.CurrentManagedThreadId));
}
}
#if ANDROID
/// <summary>
/// A high-performance, direct Android Logcat Sink utilizing native Android APIs.
/// </summary>
internal sealed class AndroidLogcatSink : ILogEventSink
{
private readonly ITextFormatter _formatter;
private const string Tag = "NexusReader";
public AndroidLogcatSink(ITextFormatter formatter)
{
_formatter = formatter ?? throw new ArgumentNullException(nameof(formatter));
}
public void Emit(LogEvent logEvent)
{
using var writer = new StringWriter();
_formatter.Format(logEvent, writer);
var message = writer.ToString().Trim();
switch (logEvent.Level)
{
case LogEventLevel.Verbose:
Android.Util.Log.Verbose(Tag, message);
break;
case LogEventLevel.Debug:
Android.Util.Log.Debug(Tag, message);
break;
case LogEventLevel.Information:
Android.Util.Log.Info(Tag, message);
break;
case LogEventLevel.Warning:
Android.Util.Log.Warn(Tag, message);
break;
case LogEventLevel.Error:
Android.Util.Log.Error(Tag, message);
break;
case LogEventLevel.Fatal:
Android.Util.Log.Wtf(Tag, message);
break;
}
}
}
#endif
+21
View File
@@ -1,5 +1,8 @@
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using NexusReader.UI.Shared @using NexusReader.UI.Shared
@using NexusReader.Maui.Infrastructure.Logging
@inject IJSRuntime JSRuntime
@inject BlazorLoggingBridge LoggingBridge
<Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly"> <Router AppAssembly="@typeof(NexusReader.UI.Shared._Imports).Assembly">
<Found Context="routeData"> <Found Context="routeData">
@@ -16,3 +19,21 @@
</LayoutView> </LayoutView>
</NotFound> </NotFound>
</Router> </Router>
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var dotNetRef = DotNetObjectReference.Create(LoggingBridge);
await JSRuntime.InvokeVoidAsync("NexusLogging.initializeBridge", dotNetRef);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[SerilogBridge] Failed to initialize Blazor/JS Bridge: {ex}");
}
}
}
}
+39 -6
View File
@@ -1,9 +1,14 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Mobile.Services; using NexusReader.Infrastructure.Mobile.Services;
using NexusReader.UI.Shared.Services; using NexusReader.UI.Shared.Services;
using NexusReader.Application; using NexusReader.Application;
using MediatR; using MediatR;
using NexusReader.Maui.Infrastructure.Logging;
using NexusReader.Maui.Infrastructure.Identity;
using NexusReader.Maui.Services;
namespace NexusReader.Maui; namespace NexusReader.Maui;
@@ -14,19 +19,33 @@ public static class MauiProgram
try try
{ {
var builder = MauiApp.CreateBuilder(); var builder = MauiApp.CreateBuilder();
// Load embedded appsettings.json configuration
var assembly = typeof(App).Assembly;
using (var stream = assembly.GetManifestResourceStream("NexusReader.Maui.appsettings.json"))
{
if (stream != null)
{
((IConfigurationBuilder)builder.Configuration).AddJsonStream(stream);
}
}
builder builder
.UseMauiApp<App>(); .UseMauiApp<App>()
.RegisterLogging();
builder.Services.AddMauiBlazorWebView(); builder.Services.AddMauiBlazorWebView();
#if DEBUG #if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools(); builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif #endif
// Interception bridge for JS/Blazor WebView logs
builder.Services.AddSingleton<BlazorLoggingBridge>();
// Minimal Infrastructure // Minimal Infrastructure
builder.Services.AddSingleton<IPlatformService, MauiPlatformService>(); builder.Services.AddSingleton<IPlatformService, MauiPlatformService>();
builder.Services.AddSingleton<INativeStorageService, MauiStorageService>(); builder.Services.AddSingleton<INativeStorageService, NexusReader.Infrastructure.Mobile.Services.MauiStorageService>();
// Minimal Identity (Safe Mode) // Minimal Identity (Safe Mode)
builder.Services.AddScoped<NexusAuthenticationStateProvider>(); builder.Services.AddScoped<NexusAuthenticationStateProvider>();
@@ -34,16 +53,30 @@ public static class MauiProgram
sp.GetRequiredService<NexusAuthenticationStateProvider>()); sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddAuthorizationCore(); builder.Services.AddAuthorizationCore();
// Basic Network // Basic Network with Secure Token Handler
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://10.0.2.2:5000") }); builder.Services.AddTransient<MobileAuthenticationHeaderHandler>();
builder.Services.AddHttpClient("NexusAPI", client =>
{
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5104";
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<MobileAuthenticationHeaderHandler>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
// UI State // UI State
builder.Services.AddScoped<IThemeService, ThemeService>(); // Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings);
builder.Services.AddSingleton<IUserPreferenceStore, MauiUserPreferenceStore>();
builder.Services.AddSingleton<IThemeService, ThemeService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>(); builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>(); builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(); builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>(); builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>(); builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddScoped<IIdentityService, IdentityService>(); builder.Services.AddScoped<IIdentityService, IdentityService>();
@@ -27,6 +27,14 @@
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.20" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.20" />
<PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.20" /> <PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.20" />
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -34,4 +42,8 @@
<ProjectReference Include="..\NexusReader.Infrastructure.Mobile\NexusReader.Infrastructure.Mobile.csproj" /> <ProjectReference Include="..\NexusReader.Infrastructure.Mobile\NexusReader.Infrastructure.Mobile.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="appsettings.json" />
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,61 @@
using System.Net.Http;
using System.Net.Http.Json;
using FluentResults;
using NexusReader.Application.DTOs.User;
using NexusReader.Domain.Enums;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Maui.Services;
public class MauiUserPreferenceStore : IUserPreferenceStore
{
private readonly IHttpClientFactory _httpClientFactory;
public MauiUserPreferenceStore(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
private HttpClient CreateClient() => _httpClientFactory.CreateClient("NexusAPI");
public async Task<Result> SaveThemePreferenceAsync(ThemeMode mode)
{
try
{
var client = CreateClient();
var response = await client.PostAsJsonAsync("identity/theme", new UpdateThemeRequest(mode));
if (response.IsSuccessStatusCode)
{
return Result.Ok();
}
var error = await response.Content.ReadAsStringAsync();
return Result.Fail($"Failed to save cloud theme preference on mobile: {error}");
}
catch (Exception ex)
{
return Result.Fail(new Error("Network error saving mobile theme preference to cloud.").CausedBy(ex));
}
}
public async Task<Result<ThemeMode>> GetThemePreferenceAsync()
{
try
{
var client = CreateClient();
var response = await client.GetAsync("identity/profile");
if (response.IsSuccessStatusCode)
{
var profile = await response.Content.ReadFromJsonAsync<UserProfileDto>();
return profile != null
? Result.Ok(profile.ThemePreference)
: Result.Fail("Failed to deserialize mobile profile response.");
}
return Result.Fail($"Failed to fetch theme preference from cloud on mobile: {response.ReasonPhrase}");
}
catch (Exception ex)
{
return Result.Fail(new Error("Network error retrieving theme preference on mobile.").CausedBy(ex));
}
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"ApiSettings": {
"BaseUrl": "http://localhost:5104"
},
"Serilog": {
"Using": [
"Serilog.Sinks.File",
"Serilog.Sinks.Debug",
"Serilog.Sinks.Async"
],
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Async",
"Args": {
"configure": [
{
"Name": "File",
"Args": {
"path": "LOG_PATH_PLACEHOLDER",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}",
"shared": true
}
}
]
}
},
{
"Name": "Debug",
"Args": {
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": [
"FromLogContext"
]
}
}
+74
View File
@@ -7,6 +7,33 @@
<base href="/" /> <base href="/" />
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" /> <link rel="stylesheet" href="_content/NexusReader.UI.Shared/app.css" />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<script>
(function () {
try {
var themeMode = localStorage.getItem('theme-mode');
var savedTheme = localStorage.getItem('theme');
var isLight = false;
if (themeMode === '2' || savedTheme === 'light') {
isLight = true;
} else if (themeMode === '1' || savedTheme === 'dark') {
isLight = false;
} else {
isLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
}
if (isLight) {
document.documentElement.classList.add('theme-light');
document.documentElement.classList.remove('theme-dark');
} else {
document.documentElement.classList.add('theme-dark');
document.documentElement.classList.remove('theme-light');
}
} catch (e) {
// Fail silently
}
})();
</script>
</head> </head>
<body> <body>
@@ -26,7 +53,54 @@
<script src="_framework/blazor.webview.js" autostart="false"></script> <script src="_framework/blazor.webview.js" autostart="false"></script>
<script src="_content/NexusReader.UI.Shared/js/d3.v7.min.js"></script> <script src="_content/NexusReader.UI.Shared/js/d3.v7.min.js"></script>
<script>
window.NexusLogging = {
initializeBridge: function (dotNetHelper) {
const originalLog = console.log;
const originalWarn = console.warn;
const originalError = console.error;
console.log = function (...args) {
originalLog.apply(console, args);
try {
dotNetHelper.invokeMethodAsync('LogJsMessage', 'info', args.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' '));
} catch (e) {}
};
console.warn = function (...args) {
originalWarn.apply(console, args);
try {
dotNetHelper.invokeMethodAsync('LogJsMessage', 'warn', args.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' '));
} catch (e) {}
};
console.error = function (...args) {
originalError.apply(console, args);
try {
dotNetHelper.invokeMethodAsync('LogJsMessage', 'error', args.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' '));
} catch (e) {}
};
window.onerror = function (message, source, lineno, colno, error) {
const stack = error ? error.stack : '';
try {
dotNetHelper.invokeMethodAsync('LogJsMessage', 'error', `${message} at ${source}:${lineno}:${colno}`, stack);
} catch (e) {}
return false;
};
window.addEventListener('unhandledrejection', function (event) {
const reason = event.reason;
const message = reason instanceof Error ? reason.message : String(reason);
const stack = reason instanceof Error ? reason.stack : '';
try {
dotNetHelper.invokeMethodAsync('LogJsMessage', 'error', `Unhandled Promise Rejection: ${message}`, stack);
} catch (e) {}
});
}
};
</script>
<script src="_content/NexusReader.UI.Shared/js/theme.js"></script>
</body> </body>
</html> </html>
@@ -0,0 +1,76 @@
@using NexusReader.Application.DTOs.AI
<div class="nexus-citation-container" @onmouseenter="ShowPopup" @onmouseleave="HidePopup">
<button type="button" class="nexus-citation-trigger" aria-label="Citation source">
<!-- Circular Neon SVG Radar Ping / Stylized Book Icon -->
<svg class="neon-radar-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="6"></circle>
<circle cx="12" cy="12" r="2"></circle>
</svg>
<span class="pulse-ring"></span>
</button>
@if (_isHovered && _citation != null)
{
<div class="nexus-citation-popup">
<div class="popup-header">
<span class="book-title"><i class="bi bi-book-half"></i> @_citation.SourceBook</span>
@if (!string.IsNullOrEmpty(_citation.Author))
{
<span class="separator">•</span>
<span class="book-author">@_citation.Author</span>
}
@if (_citation.PageNumber.HasValue)
{
<span class="separator">•</span>
<span class="page-number">Page @_citation.PageNumber.Value</span>
}
</div>
<div class="popup-body">
<p class="citation-quote">"@_citation.Snippet"</p>
</div>
<div class="popup-footer">
<span class="id-badge">ID: @SourceId.Substring(0, Math.Min(8, SourceId.Length))</span>
</div>
</div>
}
</div>
@code {
[Parameter]
[EditorRequired]
public string SourceId { get; set; } = string.Empty;
[Parameter]
public List<CitationDto>? Citations { get; set; }
private bool _isHovered;
private CitationDto? _citation;
protected override void OnParametersSet()
{
_citation = Citations?.FirstOrDefault(c => c.CitationId.Equals(SourceId, System.StringComparison.OrdinalIgnoreCase));
// If not found in the thread citations, provide a clean fallback so the UI never displays an empty error
if (_citation == null)
{
_citation = new CitationDto
{
CitationId = SourceId,
SourceBook = "Grounded Document Chunk",
Snippet = "Context snippet retrieved from vector search node."
};
}
}
private void ShowPopup()
{
_isHovered = true;
}
private void HidePopup()
{
_isHovered = false;
}
}
@@ -0,0 +1,148 @@
.nexus-citation-container {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin: 0 4px;
}
.nexus-citation-trigger {
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #06b6d4; /* Glowing Cyan */
width: 20px;
height: 20px;
position: relative;
outline: none;
transition: all 0.3s ease;
}
.nexus-citation-trigger:hover {
color: #00ff99; /* Neon Green on hover */
transform: scale(1.2);
}
.neon-radar-svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 4px currentColor);
animation: radar-spin 8s linear infinite;
}
.pulse-ring {
position: absolute;
width: 100%;
height: 100%;
border: 1px solid currentColor;
border-radius: 50%;
animation: radar-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
opacity: 0;
pointer-events: none;
}
.nexus-citation-popup {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(5px);
width: 320px;
padding: 1rem;
border-radius: 12px;
background: rgba(10, 16, 26, 0.9); /* Premium dark background */
border: 1px solid rgba(6, 182, 212, 0.25); /* Cyan border */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 12px rgba(6, 182, 212, 0.15);
z-index: 1000;
pointer-events: none;
opacity: 0;
animation: popup-fade-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: bottom center;
}
.popup-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 0.75rem;
font-weight: 700;
color: #00ff99; /* Emerald/Neon Green micro-header */
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 0.35rem;
}
.separator {
color: rgba(255, 255, 255, 0.3);
}
.book-title {
display: flex;
align-items: center;
gap: 4px;
}
.book-author, .page-number {
color: rgba(255, 255, 255, 0.6);
}
.popup-body {
margin-bottom: 0.5rem;
}
.citation-quote {
font-size: 0.85rem;
line-height: 1.4;
color: rgba(255, 255, 255, 0.95);
font-style: italic;
margin: 0;
}
.popup-footer {
display: flex;
justify-content: flex-end;
}
.id-badge {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.3);
font-family: monospace;
}
/* Animations */
@keyframes radar-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes radar-ping {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
@keyframes popup-fade-in {
0% {
opacity: 0;
transform: translateX(-50%) translateY(8px) scale(0.95);
}
100% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
@@ -2,93 +2,134 @@
@switch (Name.ToLowerInvariant()) @switch (Name.ToLowerInvariant())
{ {
case "home": case "home":
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="9 22 9 12 15 12 15 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <polyline points="9 22 9 12 15 12 15 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "map": case "map":
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "share":
case "share-2": case "share-2":
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="18" cy="19" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <circle cx="18" cy="19" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "help-circle": case "help-circle":
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="12" y1="17" x2="12.01" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="12" y1="17" x2="12.01" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "robot": case "robot":
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" /> <path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2V9c0-1.1.9-2 2-2h5V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM8 11v4h8v-4H8zm-2 0H4v4h2v-4zm14 0h-2v4h2v-4z" />
break; break;
case "play": case "play":
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
break; break;
case "check": case "check":
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
break; break;
case "search": case "search":
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /> <circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="21" y1="21" x2="16.65" y2="16.65" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
break; break;
case "message-square": case "message-square":
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "diamond": case "diamond":
<path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M12 3L3 12L12 21L21 12L12 3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "layout": case "layout":
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <rect width="18" height="18" x="3" y="3" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "book":
case "book-open": case "book-open":
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "user": case "user":
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "settings": case "settings":
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "bookmark": case "bookmark":
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /> <path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "target": case "target":
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /> <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="2" fill="none" stroke="currentColor" stroke-width="2" />
break; break;
case "trash": case "trash":
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" /> <polyline points="3 6 5 6 21 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="10" y1="11" x2="10" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="14" y1="11" x2="14" y2="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "mail": case "mail":
<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /> <rect width="20" height="16" x="2" y="4" rx="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "lock": case "lock":
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /> <rect width="18" height="11" x="3" y="11" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "eye": case "eye":
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" /> <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "eye-off": case "eye-off":
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" /> <path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="2" x2="22" y1="2" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "arrow-left": case "arrow-left":
<path d="M19 12H5M12 19l-7-7 7-7" /> <line x1="19" y1="12" x2="5" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="12 19 5 12 12 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "arrow-right": case "arrow-right":
<path d="M5 12h14M12 5l7 7-7 7" /> <line x1="5" y1="12" x2="19" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="12 5 19 12 12 19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "edit":
case "edit-2":
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "log-out": 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" /> <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;
case "chevron-left":
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "chevron-right":
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "x":
case "close":
<line x1="18" y1="6" x2="6" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="6" y1="6" x2="18" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "sun":
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "moon":
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
default: default:
<!-- Fallback circle --> <!-- Fallback circle -->
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
break; break;
} }
</svg> </svg>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,39 +1,347 @@
@namespace NexusReader.UI.Shared.Components.Atoms @namespace NexusReader.UI.Shared.Components.Atoms
@using System.Text.RegularExpressions
@using MediatR
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.Queries.Library
@inject IMediator Mediator
@inject IReaderNavigationService NavService
@inject IReaderInteractionService InteractionService
@inject NavigationManager NavManager
@inject AuthenticationStateProvider AuthStateProvider
@inject ILogger<NexusSearchBox> Logger
@implements IDisposable
<div class="nexus-search-container @(IsActive ? "active" : "")"> <div class="nexus-search-container @(IsFocused ? "focused" : "") @(HasResults ? "has-results" : "")" @onfocusin="HandleFocusIn" @onfocusout="HandleFocusOut">
<div class="search-wrapper"> <div class="search-wrapper">
<i class="nexus-icon @IconClass"></i> <div class="search-icon-container">
<input type="text" @if (_isLoading)
@bind="SearchValue" {
@bind:event="oninput" <div class="neon-spinner"></div>
@onkeypress="HandleKeyPress" }
placeholder="@Placeholder" 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" /> 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)) @if (!string.IsNullOrEmpty(SearchValue))
{ {
<button class="clear-btn" @onclick="ClearSearch">×</button> <button type="button" class="clear-btn" @onclick="ClearSearch" aria-label="Wyczyść wyszukiwanie">×</button>
} }
</div> </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> </div>
@code { @code {
[Parameter] public string Placeholder { get; set; } = "Search your library..."; [Parameter] public string Placeholder { get; set; } = "Zapytaj swoją bibliotekę AI...";
[Parameter] public string IconClass { get; set; } = "bi bi-search";
[Parameter] public EventCallback<string> OnSearch { get; set; } [Parameter] public EventCallback<string> OnSearch { get; set; }
[Parameter] public int Limit { get; set; } = 5;
private string SearchValue { get; set; } = string.Empty;
private bool IsActive => !string.IsNullOrEmpty(SearchValue);
private async Task HandleKeyPress(KeyboardEventArgs e) 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 bool _disposed;
private CancellationTokenSource? _searchCts;
private async Task HandleInput(ChangeEventArgs e)
{ {
if (e.Key == "Enter") SearchValue = e.Value?.ToString() ?? string.Empty;
_searchError = null;
if (string.IsNullOrWhiteSpace(SearchValue))
{ {
await OnSearch.InvokeAsync(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;
if (!_disposed)
{
await InvokeAsync(StateHasChanged);
}
try
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token);
if (token.IsCancellationRequested || _disposed) 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 && !_disposed)
{
_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 && !_disposed)
{
_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);
if (_disposed) return;
_isDropdownOpen = false;
StateHasChanged();
} }
private void ClearSearch() private void ClearSearch()
{ {
SearchValue = string.Empty; SearchValue = string.Empty;
_results.Clear();
_searchError = null;
_isDropdownOpen = false;
}
private string HighlightQueryWords(string text, string query)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
var escapedText = System.Net.WebUtility.HtmlEncode(text);
if (string.IsNullOrWhiteSpace(query))
return escapedText;
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length > 2)
.Select(Regex.Escape);
if (!words.Any())
return escapedText;
var pattern = "(" + string.Join("|", words) + ")";
try
{
return Regex.Replace(escapedText, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
}
catch
{
return escapedText;
}
}
public void Dispose()
{
_disposed = true;
_searchCts?.Cancel();
_searchCts?.Dispose();
} }
} }
@@ -1,57 +1,309 @@
.nexus-search-container { .nexus-search-container {
position: relative;
width: 100%; width: 100%;
max-width: 500px; max-width: 600px;
margin: 1rem auto; margin: 1.5rem auto;
transition: all 0.3s ease; font-family: var(--nexus-font-sans), 'Inter', sans-serif;
z-index: 1000;
} }
.search-wrapper { .search-wrapper {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--nexus-card, #141414); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px);
border-radius: 12px; -webkit-backdrop-filter: blur(10px);
padding: 0.5rem 1rem; border: 1px solid rgba(255, 255, 255, 0.08);
transition: border-color 0.3s ease, box-shadow 0.3s ease; border-radius: 14px;
padding: 0.65rem 1.1rem;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.nexus-search-container.active .search-wrapper, /* Focused state: glowing neon border matching other dashboard components */
.search-wrapper:focus-within { .nexus-search-container.focused .search-wrapper {
border-color: var(--nexus-neon, #00ff99); background: rgba(255, 255, 255, 0.05);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.2); border-color: var(--nexus-neon);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 255, 153, 0.25);
}
.search-icon-container {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 0.85rem;
} }
.nexus-icon { .nexus-icon {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.45);
margin-right: 0.75rem; font-size: 1.25rem;
font-size: 1.1rem; transition: color 0.3s ease;
}
.nexus-search-container.focused .nexus-icon {
color: var(--nexus-neon);
} }
.nexus-search-input { .nexus-search-input {
flex: 1; flex: 1;
background: transparent; background: transparent;
border: none; border: none;
color: white; color: #ffffff;
font-family: 'Inter', sans-serif; font-size: 1rem;
font-size: 0.95rem; font-weight: 400;
outline: none; outline: none;
padding: 0;
width: 100%;
} }
.nexus-search-input::placeholder { .nexus-search-input::placeholder {
color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.35);
font-style: italic;
transition: color 0.3s ease;
}
.nexus-search-container.focused .nexus-search-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
/* Pulsing neon-green AI status indicator */
.ai-status-indicator {
display: flex;
align-items: center;
margin: 0 0.75rem;
}
.ai-pulse-dot {
width: 8px;
height: 8px;
background-color: var(--nexus-neon);
border-radius: 50%;
display: inline-block;
position: relative;
box-shadow: 0 0 8px var(--nexus-neon);
}
.ai-pulse-dot::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: var(--nexus-neon);
border-radius: 50%;
z-index: -1;
animation: pulse 2s infinite ease-in-out;
} }
.clear-btn { .clear-btn {
background: transparent; background: transparent;
border: none; border: none;
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
font-size: 1.2rem; font-size: 1.5rem;
line-height: 1;
cursor: pointer; cursor: pointer;
padding: 0 0.5rem; padding: 0 0.25rem;
transition: color 0.2s ease; margin-left: 0.5rem;
transition: color 0.2s ease, transform 0.2s ease;
} }
.clear-btn:hover { .clear-btn:hover {
color: var(--nexus-neon, #00ff99); color: #ff3b30;
transform: scale(1.1);
}
/* Frosted glass results container */
.search-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
background: rgba(18, 18, 18, 0.9);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), 0 0 20px rgba(0, 255, 153, 0.05);
max-height: 420px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
animation: slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.search-dropdown::-webkit-scrollbar {
width: 6px;
}
.search-dropdown::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.search-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--nexus-neon);
}
/* In-flight spinners */
.neon-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(0, 255, 153, 0.15);
border-top: 2px solid var(--nexus-neon);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
.neon-spinner-large {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.05);
border-top: 3px solid var(--nexus-neon);
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
margin-bottom: 1rem;
}
.dropdown-state-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
text-align: center;
}
.state-text {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.65);
font-weight: 300;
}
.error-icon, .empty-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
}
.error-icon {
color: #ff3b30;
text-shadow: 0 0 10px rgba(255, 59, 48, 0.4);
}
.empty-icon {
color: rgba(255, 255, 255, 0.2);
}
/* Results Cards list */
.dropdown-results-list {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.result-card {
padding: 0.95rem 1.1rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
.result-card:hover {
background: rgba(0, 255, 153, 0.05);
border-color: rgba(0, 255, 153, 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.relevance-badge {
background: rgba(0, 255, 153, 0.1);
color: var(--nexus-neon);
border: 1px solid rgba(0, 255, 153, 0.25);
border-radius: 6px;
padding: 0.15rem 0.45rem;
font-weight: 600;
letter-spacing: 0.02em;
text-shadow: 0 0 4px rgba(0, 255, 153, 0.25);
}
.source-title {
color: rgba(255, 255, 255, 0.5);
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-title strong {
color: rgba(255, 255, 255, 0.85);
}
.result-snippet {
font-size: 0.88rem;
line-height: 1.45;
color: rgba(255, 255, 255, 0.78);
font-weight: 300;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Markup highlights */
::deep mark.search-highlight {
background: rgba(0, 255, 153, 0.2);
color: var(--nexus-neon);
border-bottom: 1px solid var(--nexus-neon);
padding: 0.05rem 0.15rem;
border-radius: 3px;
font-weight: 500;
}
/* Animations */
@keyframes pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 255, 153, 0.7);
}
70% {
transform: scale(1.6);
box-shadow: 0 0 0 6px rgba(0, 255, 153, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 255, 153, 0);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }

Some files were not shown because too many files have changed in this diff Show More